Compare commits
1
Commits
6dd22bcb73
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5a588177c |
@@ -8,9 +8,9 @@ and downloads individual images while decrypting them on the way to disk.
|
|||||||
quak also includes a resilient backup command that downloads every file in the
|
quak also includes a resilient backup command that downloads every file in the
|
||||||
account into a deduplicated local directory tree, skipping files that already
|
account into a deduplicated local directory tree, skipping files that already
|
||||||
exist on disk and continuing past individual download failures instead of
|
exist on disk and continuing past individual download failures instead of
|
||||||
crashing. For each file it persists the basic metadata fields quak keeps (title,
|
crashing. It decrypts and persists all three metadata layers (basic, private
|
||||||
file type, creation and modification time, latitude, longitude, content hash),
|
magic, public magic) per file, including camera info, GPS coordinates, captions,
|
||||||
and the private and public magic metadata in full. A helper subcommand can
|
and any face/keyword labels the Ente clients have added. A helper subcommand can
|
||||||
detect and regenerate missing thumbnails, encrypting and uploading them back to
|
detect and regenerate missing thumbnails, encrypting and uploading them back to
|
||||||
the server.
|
the server.
|
||||||
|
|
||||||
@@ -51,8 +51,7 @@ const client = await Client.login({
|
|||||||
|
|
||||||
// Open a cache-backed library. On an empty cache this awaits one server
|
// Open a cache-backed library. On an empty cache this awaits one server
|
||||||
// refresh; on an existing cache it returns immediately and refreshes in the
|
// refresh; on an existing cache it returns immediately and refreshes in the
|
||||||
// background. Later refreshes start `refreshIntervalSeconds` (default 3) after
|
// background every `refreshIntervalSeconds` (default 3).
|
||||||
// the previous one ends.
|
|
||||||
const lib = await Library.open({ client });
|
const lib = await Library.open({ client });
|
||||||
|
|
||||||
// Default reads answer synchronously from the local cache — no network.
|
// Default reads answer synchronously from the local cache — no network.
|
||||||
@@ -63,7 +62,7 @@ for (const album of lib.albums.list()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh reads await a server round-trip, joining one already running.
|
// Fresh reads await a server round-trip and answer with current state.
|
||||||
const { albums } = await lib.fresh();
|
const { albums } = await lib.fresh();
|
||||||
console.log(`${albums.list().length} albums as of now`);
|
console.log(`${albums.list().length} albums as of now`);
|
||||||
|
|
||||||
@@ -80,43 +79,12 @@ await lib.close();
|
|||||||
The lower-level `Client` (login, session serialization, and the raw
|
The lower-level `Client` (login, session serialization, and the raw
|
||||||
enumeration/download calls) is exported too and documented under Design below.
|
enumeration/download calls) is exported too and documented under Design below.
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
`examples/download-albums.ts` downloads every album's photos and their metadata
|
|
||||||
into a directory, `photos` in the working directory unless you name another. The
|
|
||||||
build compiles it; run it after `yarn install`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn build
|
|
||||||
QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir]
|
|
||||||
```
|
|
||||||
|
|
||||||
It opens the library with `precacheThumbnails` and `precacheOriginals` off, as
|
|
||||||
`quak backup` does, so the only file content it fetches is the originals it
|
|
||||||
saves. It asks on the terminal for a two-factor or email code when the account
|
|
||||||
requires one, and writes:
|
|
||||||
|
|
||||||
- each photo's original at its save path under `dir`, as `photo.download()`
|
|
||||||
writes it: `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, and for a live
|
|
||||||
photo its image, its video and the `.livephoto.json` file naming them
|
|
||||||
- beside each original, a JSON file named after it with `.json` added, for
|
|
||||||
example `2026-03-01.12345.jpg.json`: the photo's record (`photo.record()`)
|
|
||||||
without its cache paths, and every EXIF tag of the photo (`photo.exif()`)
|
|
||||||
under `exif`
|
|
||||||
- `albums/<collectionID>.json` for each album: its `collectionID`, its `name`,
|
|
||||||
and under `savePaths` the save paths of its photos relative to `dir`, newest
|
|
||||||
first
|
|
||||||
|
|
||||||
A photo in several albums is downloaded once. A second run downloads nothing and
|
|
||||||
rewrites only the JSON files whose content changed. A failed download stops the
|
|
||||||
run; running it again carries on, since every photo already saved is skipped.
|
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|
||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow, and most Makefile targets are thin shims that call them.
|
development workflow, and the Makefile targets are thin shims that call them.
|
||||||
The scripts are POSIX sh (not bash) so they run in minimal containers such as
|
The scripts are POSIX sh (not bash) so they run in minimal containers such as
|
||||||
alpine. We provide:
|
alpine. We provide:
|
||||||
|
|
||||||
@@ -154,9 +122,9 @@ alpine. We provide:
|
|||||||
Linting and testing are phases of the `Dockerfile`. The `lint` phase copies the
|
Linting and testing are phases of the `Dockerfile`. The `lint` phase copies the
|
||||||
repo into a digest-pinned node image and runs eslint and `prettier --check .`;
|
repo into a digest-pinned node image and runs eslint and `prettier --check .`;
|
||||||
the `test` phase does the same with the suite. `script/lint` and `script/test`
|
the `test` phase does the same with the suite. `script/lint` and `script/test`
|
||||||
each build one phase with `docker build --no-cache --target <phase>`. Neither
|
each build one phase with `docker build --no-cache --target <phase>`. There is
|
||||||
script runs the tools on the host: docker is required, and that also works where
|
no host lint or test path: docker is required, and that also works where the
|
||||||
the docker daemon is remote and bind mounts are impossible.
|
docker daemon is remote and bind mounts are impossible.
|
||||||
|
|
||||||
The last stage of the `Dockerfile` compiles the package, and it copies a file
|
The last stage of the `Dockerfile` compiles the package, and it copies a file
|
||||||
from each phase, so it cannot be built unless lint and the tests pass. That is
|
from each phase, so it cannot be built unless lint and the tests pass. That is
|
||||||
@@ -202,19 +170,16 @@ local cache is reliable, and the UI is responsive on a five-year-old laptop.
|
|||||||
|
|
||||||
All work on quak is test-driven. No exceptions.
|
All work on quak is test-driven. No exceptions.
|
||||||
|
|
||||||
1. Every change starts on a feature branch off `next`, and its pull request
|
1. Every change starts on a feature branch off `main`.
|
||||||
targets `next`.
|
|
||||||
2. The first commit on the branch is the test suite for what is being added or
|
2. The first commit on the branch is the test suite for what is being added or
|
||||||
changed. Those tests must fail at that commit; the branch is red until the
|
changed. Those tests must fail at that commit; the branch is red until the
|
||||||
implementation lands.
|
implementation lands.
|
||||||
3. Subsequent commits add the implementation and any refactors needed to make
|
3. Subsequent commits add the implementation and any refactors needed to make
|
||||||
the tests pass.
|
the tests pass.
|
||||||
4. A pull request can only be merged into `next` when `make check` is green.
|
4. A feature branch can only be merged into `main` when `make check` is green.
|
||||||
Once it has passed review, the repository manager squash-merges it into
|
`main` is always green. CI runs `script/cibuild`, which builds the
|
||||||
`next`. Only sneak merges `next` into `main`. `main` and `next` are always
|
`Dockerfile`: its `lint` and `test` phases, then the compile, so neither a
|
||||||
green. CI runs `script/cibuild`, which builds the `Dockerfile`: its `lint`
|
red branch nor one that does not compile can pass CI.
|
||||||
and `test` phases, then the compile, so neither a red branch nor one that
|
|
||||||
does not compile can pass CI.
|
|
||||||
5. Tests are the canonical API documentation for this library. Every test file
|
5. Tests are the canonical API documentation for this library. Every test file
|
||||||
is commented thoroughly enough that a reader who has never seen quak can
|
is commented thoroughly enough that a reader who has never seen quak can
|
||||||
learn how to use it from the tests alone. Comments explain why a behavior
|
learn how to use it from the tests alone. Comments explain why a behavior
|
||||||
@@ -232,7 +197,7 @@ All work on quak is test-driven. No exceptions.
|
|||||||
not the tests, and so not the full `make check`. This is deliberate so the
|
not the tests, and so not the full `make check`. This is deliberate so the
|
||||||
TDD red-phase commit (failing tests, no implementation yet) can land. The
|
TDD red-phase commit (failing tests, no implementation yet) can land. The
|
||||||
`test` phase is part of the image build, which is what CI executes via
|
`test` phase is part of the image build, which is what CI executes via
|
||||||
`script/cibuild`, so a red branch still cannot reach `next`.
|
`script/cibuild`, so a red branch still cannot reach `main`.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -244,7 +209,7 @@ the CLI is for humans.
|
|||||||
```
|
```
|
||||||
quak/
|
quak/
|
||||||
src/
|
src/
|
||||||
crypto/ libsodium primitives (boxes, secretstreams, KDF, hash)
|
crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP)
|
||||||
api/ HTTP client (ApiClient class)
|
api/ HTTP client (ApiClient class)
|
||||||
auth/ login flow (SRP + email OTP + TOTP), key unwrap
|
auth/ login flow (SRP + email OTP + TOTP), key unwrap
|
||||||
model/ decrypted Collection, File, Metadata types + decrypt fns
|
model/ decrypted Collection, File, Metadata types + decrypt fns
|
||||||
@@ -254,8 +219,7 @@ quak/
|
|||||||
and search, request pools
|
and search, request pools
|
||||||
backup.ts resilient full-account backup with dedup
|
backup.ts resilient full-account backup with dedup
|
||||||
metadata-backup.ts
|
metadata-backup.ts
|
||||||
backup-metadata: the metadata quak keeps, as JSON
|
backup-metadata: all decrypted metadata as JSON
|
||||||
exif.ts EXIF read from an image's bytes with exifreader
|
|
||||||
mldata-fetch.ts fetch + decrypt per-file ML data
|
mldata-fetch.ts fetch + decrypt per-file ML data
|
||||||
filename.ts safe file names from server metadata
|
filename.ts safe file names from server metadata
|
||||||
errors.ts error types shared across layers
|
errors.ts error types shared across layers
|
||||||
@@ -270,9 +234,6 @@ quak/
|
|||||||
index.ts public library exports
|
index.ts public library exports
|
||||||
bin/
|
bin/
|
||||||
quak.ts CLI entrypoint (commander.js)
|
quak.ts CLI entrypoint (commander.js)
|
||||||
examples/
|
|
||||||
download-albums.ts
|
|
||||||
download every album's photos and metadata
|
|
||||||
test/ unit + integration tests (vitest)
|
test/ unit + integration tests (vitest)
|
||||||
Makefile
|
Makefile
|
||||||
Dockerfile lint phase, test phase, compile
|
Dockerfile lint phase, test phase, compile
|
||||||
@@ -281,18 +242,15 @@ quak/
|
|||||||
```
|
```
|
||||||
|
|
||||||
`make build` compiles that tree into `dist/`, preserving its shape: the library
|
`make build` compiles that tree into `dist/`, preserving its shape: the library
|
||||||
lands in `dist/src/`, the examples in `dist/examples/`, and the CLI in
|
lands in `dist/src/` and the CLI in `dist/bin/quak.js`, which is what
|
||||||
`dist/bin/quak.js`, which is what `package.json` points `main`, `types` and
|
`package.json` points `main`, `types` and `bin` at. The compiler's `rootDir` is
|
||||||
`bin` at. The compiler's `rootDir` is the repository root rather than `src/`,
|
the repository root rather than `src/`, because `bin/` is compiled too and
|
||||||
because `bin/` is compiled too and `rootDir` has to contain everything that is
|
`rootDir` has to contain everything that is compiled.
|
||||||
compiled.
|
|
||||||
|
|
||||||
### Cryptography
|
### Cryptography
|
||||||
|
|
||||||
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
|
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
|
||||||
required for `crypto_pwhash` / Argon2id), except the SRP handshake, which uses
|
required for `crypto_pwhash` / Argon2id). No hand-rolled crypto.
|
||||||
`fast-srp-hap`, and the MD5 checksum sent with a thumbnail upload, which uses
|
|
||||||
Node's built-in `node:crypto`. No hand-rolled crypto.
|
|
||||||
|
|
||||||
The key hierarchy, derived during login, is:
|
The key hierarchy, derived during login, is:
|
||||||
|
|
||||||
@@ -300,23 +258,19 @@ The key hierarchy, derived during login, is:
|
|||||||
2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`,
|
2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`,
|
||||||
with server-issued `memLimit` and `opsLimit`, produces a 32-byte Key
|
with server-issued `memLimit` and `opsLimit`, produces a 32-byte Key
|
||||||
Encryption Key (KEK).
|
Encryption Key (KEK).
|
||||||
3. SRP login: `crypto_kdf_derive_from_key` (BLAKE2b) derives a 32-byte subkey
|
3. SRP login: a 16-byte SRP login subkey is derived from the KEK using
|
||||||
from the KEK with subkey id 1 and context `loginctx`. Its first 16 bytes are
|
`crypto_kdf_derive_from_key` (BLAKE2b) with subkey id 1 and context
|
||||||
the SRP password.
|
`loginctx`. That 16-byte value is the SRP password.
|
||||||
4. When SRP completes, the server returns a blob of "key attributes" plus an
|
4. After SRP completes (or after email-OTP fallback), the server returns a blob
|
||||||
encrypted auth token, or first asks for a second factor. quak answers a TOTP
|
of "key attributes" plus an encrypted auth token.
|
||||||
request with the code (`POST /users/two-factor/verify`), after which the
|
|
||||||
server returns them, and cannot answer a passkey request. When the account
|
|
||||||
has email MFA on (`isEmailMFAEnabled`), an email OTP replaces SRP and the
|
|
||||||
server returns them after it.
|
|
||||||
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
|
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
|
||||||
yields the 32-byte master key.
|
yields the 32-byte master key.
|
||||||
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master
|
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master
|
||||||
key yields the user's X25519 private key. The matching public key is
|
key yields the user's X25519 private key. The matching public key is
|
||||||
delivered in cleartext.
|
delivered in cleartext.
|
||||||
7. `crypto_box_seal_open` over the encrypted token with the user's keypair
|
7. `crypto_box_seal_open` over the encrypted token with the user's keypair
|
||||||
yields the auth token's bytes. Encoded as URL-safe base64 with padding, they
|
yields the URL-safe base64 auth token used in `X-Auth-Token` for all
|
||||||
are the `X-Auth-Token` value for all subsequent calls.
|
subsequent calls.
|
||||||
|
|
||||||
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the
|
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the
|
||||||
master key (for owned collections). Per-file keys are decrypted with
|
master key (for owned collections). Per-file keys are decrypted with
|
||||||
@@ -352,8 +306,7 @@ Endpoints used:
|
|||||||
- `POST /users/srp/create-session`: begin SRP handshake.
|
- `POST /users/srp/create-session`: begin SRP handshake.
|
||||||
- `POST /users/srp/verify-session`: complete SRP, receive 2FA challenge or the
|
- `POST /users/srp/verify-session`: complete SRP, receive 2FA challenge or the
|
||||||
encrypted token plus key attributes.
|
encrypted token plus key attributes.
|
||||||
- `POST /users/ott` and `POST /users/verify-email`: email OTP, used instead of
|
- `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path.
|
||||||
SRP when the SRP attributes have `isEmailMFAEnabled` set.
|
|
||||||
- `POST /users/two-factor/verify`: TOTP second factor.
|
- `POST /users/two-factor/verify`: TOTP second factor.
|
||||||
- `POST /users/logout`: end the calling token's session (`quak logout`).
|
- `POST /users/logout`: end the calling token's session (`quak logout`).
|
||||||
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since
|
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since
|
||||||
@@ -375,9 +328,8 @@ request is repeated only when repeating it could produce a different answer:
|
|||||||
- Every other 4xx: not retried. A 404 in particular is an answer, and
|
- Every other 4xx: not retried. A 404 in particular is an answer, and
|
||||||
`listMissingThumbnails` depends on getting it promptly and once.
|
`listMissingThumbnails` depends on getting it promptly and once.
|
||||||
- Transport failures — a `fetch` rejection, `ECONNRESET`, `ETIMEDOUT`, a DNS or
|
- Transport failures — a `fetch` rejection, `ECONNRESET`, `ETIMEDOUT`, a DNS or
|
||||||
TLS failure — and deadline aborts: retried. Node's `fetch` rejects with a
|
TLS failure — and deadline aborts: retried. The errno is looked for in the
|
||||||
plain `TypeError`, so every `TypeError` is retried. The errno is looked for in
|
error's `cause` chain, because that is where Node's `fetch` puts it.
|
||||||
the error's `cause` chain, because that is where Node's `fetch` puts it.
|
|
||||||
- A truncated download: retried.
|
- A truncated download: retried.
|
||||||
- Anything else, including a secretstream authentication failure that is not
|
- Anything else, including a secretstream authentication failure that is not
|
||||||
truncation: not retried. The default answer is no. For a backup tool, retrying
|
truncation: not retried. The default answer is no. For a backup tool, retrying
|
||||||
@@ -443,13 +395,10 @@ because a socket reset after the response headers have arrived surfaces in the
|
|||||||
download layer rather than in `ApiClient`, and that is the common failure for
|
download layer rather than in `ApiClient`, and that is the common failure for
|
||||||
multi-megabyte photos over a CDN. The secretstream pull state is not resumable
|
multi-megabyte photos over a CDN. The secretstream pull state is not resumable
|
||||||
and these endpoints have no Range support, so a retry starts the file over. The
|
and these endpoints have no Range support, so a retry starts the file over. The
|
||||||
atomic write is part of the retried unit: each attempt writes its own temporary
|
atomic write stays outside the retry, so a download that needed three attempts
|
||||||
files, one for most files and two for a live photo (its image and its video),
|
still performs exactly one write and one rename. `runBackup` and
|
||||||
and removes them if it fails. Only the attempt that completes renames anything
|
`runMetadataBackup` are unchanged: the retry sits below them, and a file that
|
||||||
into place. The retry sits below `runBackup` and `runMetadataBackup`.
|
fails after exhausting it is still logged, counted, and stepped over.
|
||||||
`runBackup` logs, counts and steps over a file that still fails after its
|
|
||||||
retries; `runMetadataBackup`, which downloads only with `--exif`, records the
|
|
||||||
error in that file's JSON as `imageMetadataError` and goes on.
|
|
||||||
|
|
||||||
One imprecision is deliberate and worth knowing about. When a body ends part-way
|
One imprecision is deliberate and worth knowing about. When a body ends part-way
|
||||||
through a secretstream chunk, Poly1305 fails and carries no framing signal, so a
|
through a secretstream chunk, Poly1305 fails and carries no framing signal, so a
|
||||||
@@ -473,11 +422,10 @@ base64-encoded keys) that the consumer can write to disk, a database, or
|
|||||||
whatever else fits their use case. `Client.fromJSON(snapshot)` restores a
|
whatever else fits their use case. `Client.fromJSON(snapshot)` restores a
|
||||||
working client from that snapshot without re-authenticating; it checks every
|
working client from that snapshot without re-authenticating; it checks every
|
||||||
field and each key's length first, and throws an error naming the bad field.
|
field and each key's length first, and throws an error naming the bad field.
|
||||||
`client.logout()` clears the token and zeroes the key buffers in place; after
|
`client.logout()` clears the token and zeroes the key buffers in place; every
|
||||||
it, every other method on that client throws. It does not contact the server, so
|
later call on that client throws. It does not contact the server, so the token
|
||||||
the token stays valid there and in any saved snapshot;
|
stays valid there and in any saved snapshot; `await client.logoutOnServer()`
|
||||||
`await client.logoutOnServer()` first ends the session on the server
|
first ends the session on the server (`POST /users/logout`).
|
||||||
(`POST /users/logout`).
|
|
||||||
|
|
||||||
The CLI stores the snapshot at the platform-appropriate data directory via
|
The CLI stores the snapshot at the platform-appropriate data directory via
|
||||||
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
|
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
|
||||||
@@ -485,17 +433,15 @@ The CLI stores the snapshot at the platform-appropriate data directory via
|
|||||||
`0600`. The key material is stored in cleartext in the JSON; treat this file as
|
`0600`. The key material is stored in cleartext in the JSON; treat this file as
|
||||||
you would treat the password itself. A missing file is reported as "not logged
|
you would treat the password itself. A missing file is reported as "not logged
|
||||||
in"; a file that exists but is corrupt is reported as such, naming the bad
|
in"; a file that exists but is corrupt is reported as such, naming the bad
|
||||||
field. Both exit with status 1, except that `quak logout` with no file says
|
field. Both exit with status 1.
|
||||||
there is no session and exits 0.
|
|
||||||
|
|
||||||
`quak logout` ends the session on the server, so the token in `session.json`
|
`quak logout` ends the session on the server, so the token in `session.json`
|
||||||
stops working even in a copy of the file, and then deletes the file. If the
|
stops working even in a copy of the file, and then deletes the file. If the
|
||||||
server call fails (or the file is corrupt), the file is still deleted, the
|
server call fails (or the file is corrupt), the file is still deleted, the
|
||||||
command says the server session could not be ended, and it exits with status 1.
|
command says the server session could not be ended, and it exits with status 1.
|
||||||
It does not delete the cache. When it knows the cache directory, from
|
It does not delete the cache: it prints the account's cache directory and says
|
||||||
`--cache-dir` or from a session file it could read, it prints it and says it
|
it still holds decrypted data (file keys in `metadata.json`, cached originals
|
||||||
still holds decrypted data (file keys in `metadata.json`, cached originals and
|
and thumbnails), for the user to delete if they want it gone.
|
||||||
thumbnails), for the user to delete if they want it gone.
|
|
||||||
|
|
||||||
### CLI surface
|
### CLI surface
|
||||||
|
|
||||||
@@ -509,19 +455,19 @@ quak files --collection <id> [--json] list files in a collec
|
|||||||
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
||||||
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
|
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
|
||||||
quak backup <dir> [--json] full incremental backup
|
quak backup <dir> [--json] full incremental backup
|
||||||
quak backup-metadata <dir> [--exif] dump the metadata quak keeps as JSON
|
quak backup-metadata <dir> [--exif] dump all decrypted metadata as JSON
|
||||||
quak helper list-missing-thumbnails [--json] find files with missing thumbnails
|
quak helper list-missing-thumbnails [--json] find files with missing thumbnails
|
||||||
quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload missing thumbnails
|
quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbnails
|
||||||
```
|
```
|
||||||
|
|
||||||
Every command except `login`, `whoami` and `logout` runs on the cache-backed
|
Every command runs on the same cache-backed library. The read commands —
|
||||||
library. The read commands — `collections`, `files`, `get`, `get-thumb`,
|
`collections`, `files`, `get`, `get-thumb`, `backup-metadata`,
|
||||||
`backup-metadata`, `helper list-missing-thumbnails` and
|
`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` — force a
|
||||||
`helper fix-missing-thumbnails` — force a fresh server round-trip before they
|
fresh server round-trip before they answer, so they report current account state
|
||||||
answer, so they report current account state rather than whatever the cache last
|
rather than whatever the cache last held. If that round-trip fails, the command
|
||||||
held. If that round-trip fails, the command prints the error on one line and
|
prints the error on one line and exits 1. `--cache-dir` overrides where the
|
||||||
exits 1. `--cache-dir` overrides where the cache lives; without it each account
|
cache lives; without it each account gets its own directory under the per-user
|
||||||
gets its own directory under the per-user cache path.
|
cache path.
|
||||||
|
|
||||||
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is
|
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is
|
||||||
accepted for backward compatibility but ignored. For a live photo, `get` writes
|
accepted for backward compatibility but ignored. For a live photo, `get` writes
|
||||||
@@ -529,25 +475,18 @@ its image and its video, each named after the title with its own extension, as
|
|||||||
Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With
|
Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With
|
||||||
`--out PATH`, the image is written to `PATH` and the video beside it, with
|
`--out PATH`, the image is written to `PATH` and the video beside it, with
|
||||||
`PATH`'s name and the video's extension; a `PATH` with the video's extension is
|
`PATH`'s name and the video's extension; a `PATH` with the video's extension is
|
||||||
refused. `backup-metadata --exif` (alias `--all`) additionally fetches each
|
refused. `backup-metadata --exif` (alias `--all`) additionally downloads each
|
||||||
file's original through the cache and records, from it or a live photo's image,
|
file to extract full EXIF/IPTC/XMP metadata. The listing and backup commands
|
||||||
its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is
|
support `--json` for machine-readable output.
|
||||||
read with [exifreader](https://github.com/mattiasw/ExifReader) from any image
|
|
||||||
format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's
|
|
||||||
`exif` field is exifreader's EXIF tag output: each tag by name, with its
|
|
||||||
`value`, `description` and `computed` value. An EXIF block exifreader finds but
|
|
||||||
reads no tag from is recorded, base64, as `exifRaw`, with the reason in
|
|
||||||
`exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails`
|
|
||||||
and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
|
|
||||||
|
|
||||||
`backup-metadata` fetches ML data in requests of up to 200 files. When a request
|
`backup-metadata` fetches ML data in requests of up to 200 files. When a request
|
||||||
fails, the error is logged, each of its files is written with the reason in an
|
still fails after its retries, the error is logged, each of its files is written
|
||||||
`mlDataError` field instead of `mlData`, and the dump goes on. The exit code is
|
with the reason in an `mlDataError` field instead of `mlData`, and the dump goes
|
||||||
non-zero if any ML data request failed.
|
on. The exit code is non-zero if any ML data request failed.
|
||||||
|
|
||||||
`helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only,
|
`helper fix-missing-thumbnails` regenerates thumbnails for baseline JPEG images
|
||||||
because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image
|
only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG
|
||||||
(PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept
|
image (PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept
|
||||||
distinct from a `failed` repair, and does not affect the exit code; a genuine
|
distinct from a `failed` repair, and does not affect the exit code; a genuine
|
||||||
failure still exits non-zero. The server accepts a new thumbnail only from the
|
failure still exits non-zero. The server accepts a new thumbnail only from the
|
||||||
file's owner and only when it is no larger than the thumbnail size it records
|
file's owner and only when it is no larger than the thumbnail size it records
|
||||||
@@ -563,36 +502,23 @@ the smallest does not.
|
|||||||
|
|
||||||
```
|
```
|
||||||
<dir>/
|
<dir>/
|
||||||
YYYY/YYYY-MM/YYYY-MM-DD/
|
originals/
|
||||||
YYYY-MM-DD.<fileID>.<ext> actual file content, at its save path (one
|
<fileID>.<ext> actual file content (one per unique file,
|
||||||
per unique file, two for a live photo: see
|
two for a live photo: see below)
|
||||||
below)
|
<fileID>.json all decrypted metadata for that file
|
||||||
YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak
|
<fileID>.livephoto.json which of a live photo's two files is which
|
||||||
keeps, and its private and public magic
|
|
||||||
metadata
|
|
||||||
YYYY-MM-DD.<fileID>.livephoto.json
|
|
||||||
which of a live photo's two files is which
|
|
||||||
collections/
|
collections/
|
||||||
<name>/
|
<name>/
|
||||||
<title> -> ../../YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>
|
<title> -> ../../originals/<fileID>.<ext> (symlink)
|
||||||
(symlink)
|
|
||||||
<name>.json collection metadata + file list
|
<name>.json collection metadata + file list
|
||||||
failures.json files that failed and have not yet succeeded
|
failures.json files that failed and have not yet succeeded
|
||||||
```
|
```
|
||||||
|
|
||||||
Each original is saved at its save path, the same path `photo.savePath` gives
|
|
||||||
and `photo.download()` writes (see Read surface below). The date is the photo's
|
|
||||||
`takenAt` (the date set in Ente if it was edited, else its creation time) in the
|
|
||||||
time zone of the machine running quak. The extension is the one in the file's
|
|
||||||
name as uploaded, case kept, or `.bin` when it has none or it holds anything but
|
|
||||||
letters and digits. When the date or the time zone changes, the next run saves
|
|
||||||
the original at its new path and leaves the old copy where it is.
|
|
||||||
|
|
||||||
`failures.json` records each failed file with the kind of failure, how many
|
`failures.json` records each failed file with the kind of failure, how many
|
||||||
times it has been tried and when it was last tried. A file leaves it once it
|
times it has been tried and when it was last tried. A file leaves it once it
|
||||||
succeeds, or once it is no longer in the library or in the backup's scope. The
|
succeeds, or once it is no longer in the library or in the backup's scope. The
|
||||||
library's `lib.backup({ includeThumbnails: true })` also writes
|
library's `lib.backup({ includeThumbnails: true })` also writes
|
||||||
`thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not.
|
`thumbnails/<fileID>.jpg` beside `originals/`; `quak backup` does not.
|
||||||
|
|
||||||
A collection's directory and JSON are named after the collection, and a symlink
|
A collection's directory and JSON are named after the collection, and a symlink
|
||||||
after the file's title, both with unsafe characters replaced. When two
|
after the file's title, both with unsafe characters replaced. When two
|
||||||
@@ -604,47 +530,45 @@ name stays the same from run to run until such a clash appears or goes away.
|
|||||||
|
|
||||||
A live photo, which Ente stores as one ZIP of its image and its video, is stored
|
A live photo, which Ente stores as one ZIP of its image and its video, is stored
|
||||||
as those two files, which a photo viewer can open: each is
|
as those two files, which a photo viewer can open: each is
|
||||||
`YYYY-MM-DD.<fileID>.<ext>` with the extension it has inside the ZIP (for
|
`originals/<fileID>.<ext>` with the extension it has inside the ZIP (for example
|
||||||
example `2026-03-01.12345.heic` and `2026-03-01.12345.mov`), and
|
`12345.heic` and `12345.mov`), and `<fileID>.livephoto.json` names the two. The
|
||||||
`YYYY-MM-DD.<fileID>.livephoto.json` names the two. The live photo counts as
|
live photo counts as stored only when both files are present and not empty. Its
|
||||||
stored only when both files are present and not empty. Its album folder links
|
album folder links both, each named after the title with that file's extension
|
||||||
both, each named after the title with that file's extension (`IMG_0001.heic` and
|
(`IMG_0001.heic` and `IMG_0001.mov`). A live photo that an earlier version of
|
||||||
`IMG_0001.mov`).
|
quak stored as the ZIP, under the image's name, is replaced by its two files on
|
||||||
|
the next run, and the ZIP and its link are removed.
|
||||||
|
|
||||||
Each run removes the symlinks into the date folders that no longer belong in
|
Each run removes the symlinks into `originals/` that no longer belong in their
|
||||||
their collection's directory, and the directories (and JSON) of collections that
|
collection's directory, and the directories (and JSON) of collections that were
|
||||||
were deleted or renamed. Nothing else in `collections/` is touched: a file or a
|
deleted or renamed. Nothing else in `collections/` is touched: a file or a
|
||||||
symlink you put there stays, and a directory that still holds one after its
|
symlink you put there stays, and a directory that still holds one after its
|
||||||
symlinks are removed stays too, with its JSON.
|
symlinks are removed stays too, with its JSON.
|
||||||
|
|
||||||
Each file is downloaded exactly once regardless of how many collections it
|
Each file is downloaded exactly once regardless of how many collections it
|
||||||
appears in, and written once: straight to its save path, with no copy left in
|
appears in, and written once: straight into `originals/`, with no copy left in
|
||||||
the cache. An original the cache already held is copied from there instead. On
|
the cache. An original the cache already held is copied from there instead. On
|
||||||
subsequent runs, existing originals are skipped. If a download fails, the error
|
subsequent runs, existing originals are skipped. If a download fails, the error
|
||||||
is logged and the backup continues with the next file. The exit code is non-zero
|
is logged and the backup continues with the next file. The exit code is non-zero
|
||||||
if any files failed. `quak backup` opens its library with the thumbnail and
|
if any files failed. `quak backup` opens its library with the thumbnail and
|
||||||
originals precache off, so the only file content it fetches is the originals the
|
originals precache off, so it fetches only what the backup stores.
|
||||||
backup stores. The library's ML data fetch still runs and fills the cache's
|
|
||||||
`mldata/`.
|
|
||||||
|
|
||||||
Each original is written to a temporary file in the same directory, synced to
|
Each original is written to a temporary file in the same directory, synced to
|
||||||
disk, and renamed into place, so an original is either complete or absent, even
|
disk, and renamed into place, so an original is either complete or absent, even
|
||||||
after a power cut. A downloaded original's temporary file is named
|
after a power cut. A downloaded original's temporary file is named
|
||||||
`.quak-<pid>-<random>.tmp`, one copied from the cache
|
`.quak-<pid>-<random>.tmp`, one copied from the cache
|
||||||
`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A run that is
|
`.quak-backup-<fileID>.<ext>-<pid>-<random>.tmp`. A run that is killed can leave
|
||||||
killed can leave one of these temporary files behind; the next backup deletes
|
one of these temporary files behind; the next backup deletes those whose process
|
||||||
those whose process is no longer running. The content cache uses the same
|
is no longer running. The content cache uses the same scheme, and opening a
|
||||||
scheme, and opening a library deletes the temporary files in the cache whose
|
library deletes the temporary files in the cache whose process is no longer
|
||||||
process is no longer running, so a download another process has in progress in
|
running, so a download another process has in progress in the same cache is left
|
||||||
the same cache is left alone. The rename replaces whatever was at the
|
alone. The rename replaces whatever was at the destination rather than writing
|
||||||
destination rather than writing through it: a symlink there is replaced, not
|
through it: a symlink there is replaced, not followed, and the new file has the
|
||||||
followed, and the new file has the temporary file's permissions, not those of
|
temporary file's permissions, not those of the file it replaced.
|
||||||
the file it replaced.
|
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- [x] Retry policy: no retry on 4xx (except `408` and `429`), exponential
|
- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
||||||
backoff on 5xx and network errors
|
errors
|
||||||
- [x] Update the API reference section below to match the current implementation
|
- [x] Update the API reference section below to match the current implementation
|
||||||
- [x] `make docker` green
|
- [x] `make docker` green
|
||||||
- [x] Store live photos in a form a photo viewer can open
|
- [x] Store live photos in a form a photo viewer can open
|
||||||
@@ -667,8 +591,7 @@ Future (desktop client, separate repo):
|
|||||||
The library's primary surface is the cache-backed `Library`; the lower-level
|
The library's primary surface is the cache-backed `Library`; the lower-level
|
||||||
`Client` sits underneath it and is covered by the Design sections above. The
|
`Client` sits underneath it and is covered by the Design sections above. The
|
||||||
test suite is the canonical, executable documentation — `test/library/` and
|
test suite is the canonical, executable documentation — `test/library/` and
|
||||||
`test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts`
|
`test/client/usage.test.ts` walk every operation, and `yarn test` verifies them.
|
||||||
walks `lib.backup()`, and `yarn test` verifies them.
|
|
||||||
|
|
||||||
### Opening a library
|
### Opening a library
|
||||||
|
|
||||||
@@ -682,24 +605,21 @@ background, so an unreachable server does not block opening.
|
|||||||
`LibraryOptions`:
|
`LibraryOptions`:
|
||||||
|
|
||||||
| Option | Default | Meaning |
|
| Option | Default | Meaning |
|
||||||
| ------------------------ | -------------------------------------------------- | -------------------------------------------------------------------- |
|
| ------------------------ | --------------------------- | --------------------------------------------------------------------- |
|
||||||
| `client` | required | the account client (a `Client`, or any `LibraryClient`) |
|
| `client` | required | the account client (a `Client`, or any `LibraryClient`) |
|
||||||
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
|
| `cacheDirectory` | `<XDG cache>/quak/<userID>` | where `metadata.json` and the content cache live |
|
||||||
| `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached |
|
| `downloadDirectory` | none | backup destination; an original already stored there counts as cached |
|
||||||
| `refreshIntervalSeconds` | `3` | background refresh cadence |
|
| `refreshIntervalSeconds` | `3` | background refresh cadence |
|
||||||
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
|
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
|
||||||
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
|
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
|
||||||
| `precacheOriginalsDays` | `7` | length in days of that latest window |
|
| `precacheOriginalsDays` | `7` | length in days of that latest window |
|
||||||
| `cacheOriginalsMaxBytes` | 100 GiB | size limit on the originals cache; pinned originals can exceed it |
|
| `cacheOriginalsMaxBytes` | 100 GiB | hard ceiling on the originals cache |
|
||||||
| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down |
|
| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down |
|
||||||
| `isOriginalPinned` | none | extra predicate for originals that must never be evicted |
|
| `isOriginalPinned` | none | extra predicate for originals that must never be evicted |
|
||||||
| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) |
|
| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) |
|
||||||
| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) |
|
| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) |
|
||||||
| `contentSource` | the client's own | override the byte source (mainly for tests) |
|
| `contentSource` | the client's own | override the byte source (mainly for tests) |
|
||||||
|
|
||||||
The default `downloadDirectory` is resolved against the working directory once,
|
|
||||||
when the library opens; `lib.downloadDirectory` holds the result.
|
|
||||||
|
|
||||||
Concurrency is set through `pools`: construct
|
Concurrency is set through `pools`: construct
|
||||||
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
||||||
and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
|
and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
|
||||||
@@ -715,21 +635,17 @@ can then be removed.
|
|||||||
### Default reads vs. fresh reads
|
### Default reads vs. fresh reads
|
||||||
|
|
||||||
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
|
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
|
||||||
synchronously from the copy held in RAM and never touch the network. A refresh
|
synchronously from the last refreshed copy held in RAM and never touch the
|
||||||
changes that copy only once all its server requests have succeeded, and the
|
network. The background timer refreshes that copy every
|
||||||
background timer starts the next refresh `refreshIntervalSeconds` after the
|
`refreshIntervalSeconds`, so a default read is immediate but may be up to one
|
||||||
previous one ends. So a default read is immediate, but only as current as the
|
interval stale.
|
||||||
last refresh whose requests all succeeded; right after opening an existing
|
|
||||||
cache, it is the copy on disk.
|
|
||||||
|
|
||||||
`await lib.fresh()` waits for a refresh to complete and persist, and returns the
|
`await lib.fresh()` forces a refresh, waits for it to complete and persist, and
|
||||||
same `{ albums, photos, timeline }` namespaces, which then reflect a completed
|
returns the same `{ albums, photos, timeline }` namespaces — now guaranteed to
|
||||||
server round-trip. When a refresh is already running, background or not,
|
reflect a completed server round-trip. Concurrent `fresh()` calls coalesce onto
|
||||||
`fresh()` waits for that one, so its answer can come from requests made before
|
one refresh, and a refresh that fails rejects the caller (default reads stay
|
||||||
the call; only when none is running does it start one. A refresh that fails
|
silent and keep serving the last good copy). The CLI's read commands use fresh
|
||||||
rejects the caller (default reads stay silent and keep serving the last good
|
reads (issue https://git.eeqj.de/sneak/quak/issues/75).
|
||||||
copy). The CLI's read commands use fresh reads (issue
|
|
||||||
https://git.eeqj.de/sneak/quak/issues/75).
|
|
||||||
|
|
||||||
### Read surface
|
### Read surface
|
||||||
|
|
||||||
@@ -746,61 +662,17 @@ https://git.eeqj.de/sneak/quak/issues/75).
|
|||||||
`includeArchived`; hidden photos are always excluded.
|
`includeArchived`; hidden photos are always excluded.
|
||||||
|
|
||||||
An `Album` exposes its record fields and `album.photos.list()` → `Photo[]`
|
An `Album` exposes its record fields and `album.photos.list()` → `Photo[]`
|
||||||
(newest first). A `Photo` exposes its record fields other than `thumbnailPath`
|
(newest first). A `Photo` exposes its record fields, `photo.record()` →
|
||||||
and `originalPath`, and `photo.year`, the local-time year of `takenAt`, all as
|
`PhotoRecord`, and two content methods:
|
||||||
synchronous getters read from RAM; `photo.record()` → `PhotoRecord`. Two more
|
|
||||||
synchronous getters look at the disk and never touch the network:
|
|
||||||
|
|
||||||
- `photo.savePath` → `string` — where `photo.download()` and `lib.backup()` put
|
|
||||||
the original, whether or not it is there yet:
|
|
||||||
`YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's
|
|
||||||
`downloadDirectory` (see Backup layout above for the date and the extension).
|
|
||||||
For a live photo already stored, its image. For a live photo not yet stored,
|
|
||||||
it carries the title's extension, and the image may be stored under a
|
|
||||||
different one, found inside the live photo. It needs no content source.
|
|
||||||
- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. A
|
|
||||||
copy only in the cache does not count.
|
|
||||||
|
|
||||||
These async methods may download:
|
|
||||||
|
|
||||||
- `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
|
- `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
|
||||||
full-resolution file. For a live photo, `path` and `bytes` are its image's and
|
full-resolution file. For a live photo, `path` and `bytes` are its image's and
|
||||||
`videoPath` is its video.
|
`videoPath` is its video.
|
||||||
- `await photo.download()` → `{ path, bytes, videoPath? }`, as `original()` —
|
|
||||||
puts the original at `savePath`, creating its folders. When it is already
|
|
||||||
there, nothing is written. When the cache holds it, it is copied from the
|
|
||||||
cache; otherwise it is fetched straight to `savePath`, with no copy left in
|
|
||||||
the cache. Afterwards `isLocal` is true.
|
|
||||||
- `await photo.thumbnail(opts?)` → `{ path, bytes }`.
|
- `await photo.thumbnail(opts?)` → `{ path, bytes }`.
|
||||||
- `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read
|
|
||||||
through `original()`; for a live photo, its image's.
|
|
||||||
- `await photo.exif(opts?)` → `ExifTags` — every EXIF tag in the file, keyed by
|
|
||||||
tag name, each as exifreader decodes it, with its `id`, `value`, `description`
|
|
||||||
and `computed` value: for example `Make` is
|
|
||||||
`{ id: 271, value: ["Canon"], description: "Canon", computed: "Canon" }`. A
|
|
||||||
tag exifreader has no name for is keyed `undefined-<tag number>`. The embedded
|
|
||||||
thumbnail's tags are under `Thumbnail`, so they cannot hide the main image's
|
|
||||||
tags of the same name; the thumbnail image itself is left out. EXIF is read
|
|
||||||
from any image format exifreader reads (such as JPEG, HEIC/HEIF, AVIF, PNG,
|
|
||||||
WebP and TIFF), a live photo's image included. Any other original gives `{}`,
|
|
||||||
and a video gives `{}` without being downloaded.
|
|
||||||
- `await photo.make(opts?)`, and likewise `model()`, `lensModel()`,
|
|
||||||
`dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`,
|
|
||||||
`iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()`
|
|
||||||
and `gpsAltitude()` → one common field each, picked from the tags `exif()`
|
|
||||||
returns and typed as in `PhotoExif`, or `undefined` when the file lacks it.
|
|
||||||
GPS values are signed decimal degrees and metres. `dateTimeOriginal()` is the
|
|
||||||
camera's clock reading held in the `Date`'s UTC fields;
|
|
||||||
`offsetTimeOriginal()`, when present, is that clock's offset from UTC. Each
|
|
||||||
calls `exif()` with its `opts`, so each call reads the original again.
|
|
||||||
|
|
||||||
They serve from the on-disk content cache when the bytes are present and
|
Both serve from the on-disk content cache when the bytes are present and
|
||||||
otherwise fetch through the pools; `original()`, `content()` and `exif()` also
|
otherwise fetch through the pools; `opts.onProgress` reports per-file progress.
|
||||||
serve an original already stored at its save path. `opts.onProgress` reports
|
They throw when the library was opened without a content source.
|
||||||
per-file progress. They throw when the library was opened without a content
|
|
||||||
source. An original that `original()`, `content()` or `exif()` downloads lands
|
|
||||||
in the cache, which does not make `isLocal` true; only `download()` and
|
|
||||||
`lib.backup()` do.
|
|
||||||
|
|
||||||
Lower-level accessors that return decrypted model objects (which hold key
|
Lower-level accessors that return decrypted model objects (which hold key
|
||||||
material) are also available: `listCollections()`, `getCollection(id)`,
|
material) are also available: `listCollections()`, `getCollection(id)`,
|
||||||
@@ -812,12 +684,10 @@ material) are also available: `listCollections()`, `getCollection(id)`,
|
|||||||
The GUI-facing records hold no key material and no binary, so they survive
|
The GUI-facing records hold no key material and no binary, so they survive
|
||||||
`structuredClone`/JSON across the Electron IPC boundary:
|
`structuredClone`/JSON across the Electron IPC boundary:
|
||||||
|
|
||||||
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt`
|
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` (milliseconds),
|
||||||
(milliseconds), `fileType`, optional `caption` / `width` / `height` /
|
`fileType`, optional `caption` / `width` / `height` / `latitude` /
|
||||||
`latitude` / `longitude`, optional `hash` (the content hash recorded at
|
`longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath`
|
||||||
upload; very old files have none), `isArchived`, `isHidden`, and
|
once the bytes are cached (for a live photo, `originalPath` is its image).
|
||||||
`thumbnailPath` / `originalPath` once the bytes are cached (for a live photo,
|
|
||||||
`originalPath` is its image).
|
|
||||||
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
|
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
|
||||||
`fileIDs` (newest first).
|
`fileIDs` (newest first).
|
||||||
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
|
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
|
||||||
@@ -842,16 +712,16 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
|
|||||||
`SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first,
|
`SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first,
|
||||||
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
|
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
|
||||||
a query vector the caller produced elsewhere.
|
a query vector the caller produced elsewhere.
|
||||||
- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as
|
- `await lib.backup(opts?)` → `BackupResult`. It refreshes, fetches every
|
||||||
`fresh()` does, puts every in-scope original not already at its save path
|
in-scope original not already in the backup (and, with `includeThumbnails`,
|
||||||
there as `photo.download()` does (and, with `includeThumbnails`, fetches
|
|
||||||
thumbnails) through the content cache, and rebuilds the on-disk backup tree
|
thumbnails) through the content cache, and rebuilds the on-disk backup tree
|
||||||
with a durable failure ledger. A fetched original is written straight to its
|
with a durable failure ledger. A fetched original is written straight into the
|
||||||
save path and not into the cache, which then counts it as present; one the
|
backup's `originals/` and not into the cache, which then counts it as present;
|
||||||
cache already held is copied from there. `BackupOptions`: `downloadDirectory`
|
one the cache already held is copied from there. `BackupOptions`:
|
||||||
(falls back to the library's), `includeOriginals` (default `true`),
|
`downloadDirectory` (falls back to the one `open()` was given),
|
||||||
`includeThumbnails` (default `false`), `onlyAlbumNames`, and `onProgress`. See
|
`includeOriginals` (default `true`), `includeThumbnails` (default `false`),
|
||||||
Backup layout above for the tree it writes.
|
`onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it
|
||||||
|
writes.
|
||||||
|
|
||||||
### Request pools
|
### Request pools
|
||||||
|
|
||||||
@@ -882,9 +752,12 @@ When `metadata.json` belongs to a different account than the client's,
|
|||||||
originals and thumbnails are kept; they are reached only through the files the
|
originals and thumbnails are kept; they are reached only through the files the
|
||||||
current account's records name.
|
current account's records name.
|
||||||
|
|
||||||
A live photo's original is cached as at its save path: its image and its video,
|
A live photo's original is cached as in the backup: its image and its video,
|
||||||
each `originals/<fileID>.<ext>` with its own extension, and
|
each `originals/<fileID>.<ext>` with its own extension, and
|
||||||
`originals/<fileID>.livephoto.json` naming them; the two are evicted together.
|
`originals/<fileID>.livephoto.json` naming them; the two are evicted together. A
|
||||||
|
live photo that an earlier version cached as its ZIP is not served: the library
|
||||||
|
removes the ZIP when it opens the cache, and fetches the two files when the
|
||||||
|
photo is next read or precached.
|
||||||
|
|
||||||
A stored file appears only via an atomic temp-then-rename, so its presence means
|
A stored file appears only via an atomic temp-then-rename, so its presence means
|
||||||
it is complete. Every downloaded original (by `quak get`, the cache, or
|
it is complete. Every downloaded original (by `quak get`, the cache, or
|
||||||
@@ -903,13 +776,12 @@ from a very old client, is stored unchecked.
|
|||||||
- `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`,
|
- `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`,
|
||||||
`LibraryClient`, `RefreshEvent`
|
`LibraryClient`, `RefreshEvent`
|
||||||
- `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`,
|
- `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`,
|
||||||
`TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`, `SavePathLookup`
|
`TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`
|
||||||
- `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`,
|
- `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`,
|
||||||
`EnsureOptions`, `EnsureResult`, `ContentSource`
|
`EnsureOptions`, `EnsureResult`, `ContentSource`
|
||||||
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
|
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
|
||||||
`LibraryChange`
|
`LibraryChange`
|
||||||
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
|
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
|
||||||
- `src/exif.ts`: `ExifTags`, `PhotoExif`
|
|
||||||
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
|
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
|
||||||
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
|
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
|
||||||
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
|
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
|
||||||
@@ -941,15 +813,14 @@ documents:
|
|||||||
`yarn.lock`. Never `git add -A`. Never force-push to main.
|
`yarn.lock`. Never `git add -A`. Never force-push to main.
|
||||||
|
|
||||||
- **The "Development workflow" section above.** All changes go on feature
|
- **The "Development workflow" section above.** All changes go on feature
|
||||||
branches off `next`, and every pull request targets `next`; only sneak merges
|
branches. Tests are written first and committed in a failing state before the
|
||||||
`next` into `main`. Tests are written first and committed in a failing state
|
implementation. Tests are the canonical API documentation and must be
|
||||||
before the implementation. Tests are the canonical API documentation and must
|
commented thoroughly. `main` is always green.
|
||||||
be commented thoroughly. `main` and `next` are always green.
|
|
||||||
|
|
||||||
- **Required checks before every commit:** `make lint` must pass — that is
|
- **Required checks before every commit:** `make lint` must pass — that is
|
||||||
eslint plus the prettier check, and it builds the `lint` phase of the
|
eslint plus the prettier check, and it builds the `lint` phase of the
|
||||||
`Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that.
|
`Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that.
|
||||||
`make check` (which also runs the tests) must pass before merging into `next`.
|
`make check` (which also runs the tests) must pass before merging to `main`.
|
||||||
`make fmt-check` is available for a host-side formatting check on its own, but
|
`make fmt-check` is available for a host-side formatting check on its own, but
|
||||||
it is not a separate requirement: `make lint` already covers it, and running
|
it is not a separate requirement: `make lint` already covers it, and running
|
||||||
both would check formatting twice. Never invoke eslint or prettier directly;
|
both would check formatting twice. Never invoke eslint or prettier directly;
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
- branch from `next`
|
- branch (from `main`)
|
||||||
- do the work in Next Step
|
- do the work in Next Step
|
||||||
- move Next Step to the top of Completed Steps
|
- move Next Step to the top of Completed Steps
|
||||||
- move the top item of Future Steps into Next Step
|
- move the top item of Future Steps into Next Step
|
||||||
- commit (`TODO.md` changes in the same commit as the work)
|
- commit (`TODO.md` changes in the same commit as the work)
|
||||||
|
- merge to `main` if the branch is not protected, otherwise open a PR
|
||||||
- push
|
- push
|
||||||
- open a pull request that targets `next`
|
|
||||||
- once the pull request has passed review, the repository manager squash-merges
|
|
||||||
it into `next`
|
|
||||||
- only sneak merges `next` into `main`
|
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
@@ -17,109 +14,13 @@ pre-1.0
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
None: no implementation work is open. The cache design,
|
None: every issue still open is done on `next` and waits for it to reach `main`.
|
||||||
https://git.eeqj.de/sneak/quak/issues/36, waits on sneak's review.
|
|
||||||
|
|
||||||
Tagging and releases are decided by sneak alone, and happen only when he
|
Tagging and releases are decided by sneak alone, and happen only when he
|
||||||
declares one.
|
declares one.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-02: `photo.exif()` returns every EXIF tag in the file as `ExifTags`,
|
|
||||||
keyed by tag name, each as exifreader decodes it, not only the thirteen common
|
|
||||||
fields (issue 156). The embedded thumbnail's tags are under `Thumbnail`,
|
|
||||||
without the thumbnail image. The thirteen typed methods stay, each picking its
|
|
||||||
field from the tags `exif()` returns, typed as in `PhotoExif`. The example
|
|
||||||
script's JSON files now carry every tag.
|
|
||||||
|
|
||||||
- 2026-10-01: The content cache no longer looks for a live photo that an earlier
|
|
||||||
version cached as one ZIP (issue 151). When the cache opens, a live photo's
|
|
||||||
file that no JSON file names is now always left alone.
|
|
||||||
|
|
||||||
- 2026-10-01: `examples/download-albums.ts` logs in, opens the library, and for
|
|
||||||
every album downloads each photo to its save path, writes the photo's record
|
|
||||||
and EXIF fields to a JSON file beside it, and writes the album's photos to
|
|
||||||
`albums/<collectionID>.json` (issue 144). The build compiles it to
|
|
||||||
`dist/examples/`; the README's "Examples" section says how to run it.
|
|
||||||
|
|
||||||
- 2026-10-01: A `Photo` has one async method for each field of `exif()`, named
|
|
||||||
and typed as in `PhotoExif`: `make()`, `model()`, `lensModel()`,
|
|
||||||
`dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`,
|
|
||||||
`iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()`
|
|
||||||
and `gpsAltitude()` (issue 148). Each calls `exif()` and returns its one
|
|
||||||
field, or undefined when the file lacks it. `Photo` implements a type with one
|
|
||||||
method per `PhotoExif` field, so the build's type check fails when a field has
|
|
||||||
no method. A test checks, on the JPEG and the HEIC, that each method gives the
|
|
||||||
same value as `exif()`.
|
|
||||||
|
|
||||||
- 2026-10-01: Each original's save path is
|
|
||||||
`YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's
|
|
||||||
download directory, which defaults to `photos` in the working directory (issue
|
|
||||||
143). The date is the photo's `takenAt` in the machine's time zone.
|
|
||||||
`photo.savePath` is always a string, with or without a content cache, and
|
|
||||||
`isLocal` is true only when the original is at its save path.
|
|
||||||
`photo.download()` puts the original there, copied from the cache when the
|
|
||||||
cache holds it and fetched otherwise. `lib.backup()` does the same for each
|
|
||||||
file, writes each file's JSON beside its original, and links `collections/` to
|
|
||||||
the save paths.
|
|
||||||
|
|
||||||
- 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from
|
|
||||||
HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and
|
|
||||||
the other image formats `exifreader` reads (issue 145). `exifreader` replaces
|
|
||||||
`exif-reader` and the JPEG segment scan; `PhotoExif` is unchanged. The `exif`
|
|
||||||
field of `backup-metadata --exif` is now exifreader's tag output, and
|
|
||||||
`exifRaw` holds the whole EXIF block it could not read. The tests use a real
|
|
||||||
HEIC, `test/exif.heic`.
|
|
||||||
|
|
||||||
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
|
|
||||||
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
|
|
||||||
`lib.backup()` writes the original under the library's download directory; for
|
|
||||||
a live photo not yet stored, it carries the title's extension, and the backup
|
|
||||||
may store the image under a different one. `isLocal` says whether the whole
|
|
||||||
original is there. Both look only at the disk. `content()` returns the
|
|
||||||
original's bytes and `exif()` the common EXIF fields of a JPEG; both may
|
|
||||||
download the original, and `exif()` downloads no video. `PhotoRecord` gains
|
|
||||||
`modifiedAt` and `hash`, and the JPEG EXIF scan moved to `src/exif.ts`.
|
|
||||||
|
|
||||||
- 2026-09-29: The "Workflow" list at the top of this file now says to branch
|
|
||||||
from `next` and open a pull request that targets `next`, that the repository
|
|
||||||
manager squash-merges a reviewed pull request into `next`, and that only sneak
|
|
||||||
merges `next` into `main`, as the README does (issue 137).
|
|
||||||
|
|
||||||
- 2026-09-29: The README's "Development workflow" and "For LLMs" sections now
|
|
||||||
say work branches from `next`, every pull request targets `next`, the
|
|
||||||
repository manager squash-merges reviewed pull requests into `next`, and only
|
|
||||||
sneak merges `next` into `main` (issue 135).
|
|
||||||
|
|
||||||
- 2026-09-29: Brought the README and this file in line with `next` after the
|
|
||||||
milestone merge (issue 132). The Next Step says no implementation work is open
|
|
||||||
and the cache design (issue 36) waits on sneak's review. README corrections:
|
|
||||||
the Getting Started comments say when the library refreshes; most, not all,
|
|
||||||
Makefile targets call a script; `script/lint` and `script/test` have no host
|
|
||||||
path, though `yarn test` does; the SRP handshake uses `fast-srp-hap`, outside
|
|
||||||
`crypto/`, and a thumbnail upload's MD5 uses `node:crypto`; the SRP password
|
|
||||||
is the first 16 bytes of a 32-byte subkey; the key attributes and token come
|
|
||||||
after SRP, after the TOTP code SRP may ask for, or after the email OTP that
|
|
||||||
replaces SRP when the account has email MFA on, and quak cannot answer a
|
|
||||||
passkey; the auth token is sent as URL-safe base64 with padding; every
|
|
||||||
`TypeError` is retried; each download attempt writes its own temporary files,
|
|
||||||
two for a live photo, and only the attempt that completes renames them into
|
|
||||||
place; `runMetadataBackup` records a failed download in the file's JSON; a
|
|
||||||
second `client.logout()` does not throw; `quak logout` with no session exits
|
|
||||||
0, and names the cache directory only when it knows it; `login`, `whoami` and
|
|
||||||
`logout` open no library; `--exif` records XMP and, for a JPEG, EXIF, and no
|
|
||||||
IPTC; which commands take `--json`; a failed ML data request may not have been
|
|
||||||
retried; the thumbnail fixer is not limited to baseline JPEG; `quak backup`
|
|
||||||
still fetches ML data; a backup's JSON holds the basic metadata fields quak
|
|
||||||
keeps and the private and public magic metadata, not every decrypted field,
|
|
||||||
and the README no longer lists what the magic metadata holds; the default
|
|
||||||
cache directory is the per-user one, not an XDG path on macOS; pinned
|
|
||||||
originals can exceed `cacheOriginalsMaxBytes`; which tests cover which
|
|
||||||
operations; a default read is only as current as the last refresh whose
|
|
||||||
requests all succeeded; `fresh()` and `lib.backup()` join a refresh already
|
|
||||||
running; a `Photo` has no `thumbnailPath` or `originalPath`; and `408` and
|
|
||||||
`429` are retried.
|
|
||||||
|
|
||||||
- 2026-09-28: Tested the live-photo writer's fsyncs (issue 130). A test checks
|
- 2026-09-28: Tested the live-photo writer's fsyncs (issue 130). A test checks
|
||||||
that the image's and the video's temp files are fsynced before either is
|
that the image's and the video's temp files are fsynced before either is
|
||||||
renamed into place, and the directory after both renames, as the `writeAtomic`
|
renamed into place, and the directory after both renames, as the `writeAtomic`
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
// Download every album's photos to a directory, with each photo's metadata
|
|
||||||
// beside it, using only quak's public API. The README's "Examples" section
|
|
||||||
// describes the files it writes.
|
|
||||||
//
|
|
||||||
// yarn build
|
|
||||||
// QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir]
|
|
||||||
|
|
||||||
import { realpathSync } from "node:fs";
|
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
||||||
import { join, relative } from "node:path";
|
|
||||||
import { stdin, stdout } from "node:process";
|
|
||||||
import { createInterface } from "node:readline/promises";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
import { Client, Library } from "../src/index.js";
|
|
||||||
|
|
||||||
// Write `text` to `path` unless the file already holds exactly that, so a
|
|
||||||
// second run rewrites nothing.
|
|
||||||
async function writeIfChanged(path: string, text: string): Promise<void> {
|
|
||||||
const current = await readFile(path, "utf-8").catch(() => undefined);
|
|
||||||
if (current !== text) await writeFile(path, text);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pretty = (value: unknown): string =>
|
|
||||||
JSON.stringify(value, null, 2) + "\n";
|
|
||||||
|
|
||||||
// For every album in `lib`, download each photo to its save path, write the
|
|
||||||
// photo's metadata to `{savePath}.json`, and write the album's photos to
|
|
||||||
// `{dir}/albums/{collectionID}.json`. Returns how many photos it downloaded
|
|
||||||
// and how many were already at their save paths.
|
|
||||||
export async function downloadAlbums(
|
|
||||||
lib: Library,
|
|
||||||
dir: string,
|
|
||||||
): Promise<{ downloaded: number; alreadyLocal: number }> {
|
|
||||||
let downloaded = 0;
|
|
||||||
let alreadyLocal = 0;
|
|
||||||
// A photo in several albums is handled once.
|
|
||||||
const done = new Set<number>();
|
|
||||||
// fresh() waits for a refresh from the server and throws if it fails, so
|
|
||||||
// albums and photos added since the cache was last written are included.
|
|
||||||
const { albums } = await lib.fresh();
|
|
||||||
for (const album of albums.list()) {
|
|
||||||
const savePaths: string[] = [];
|
|
||||||
for (const photo of album.photos.list()) {
|
|
||||||
if (!done.has(photo.fileID)) {
|
|
||||||
done.add(photo.fileID);
|
|
||||||
if (photo.isLocal) alreadyLocal++;
|
|
||||||
else downloaded++;
|
|
||||||
await photo.download();
|
|
||||||
// The cache paths say where quak's cache keeps copies, not
|
|
||||||
// anything about the photo.
|
|
||||||
const record = { ...photo.record() };
|
|
||||||
delete record.thumbnailPath;
|
|
||||||
delete record.originalPath;
|
|
||||||
const exif = await photo.exif();
|
|
||||||
// savePath is read after download(): a live photo's names its
|
|
||||||
// image only once the image is stored.
|
|
||||||
await writeIfChanged(
|
|
||||||
`${photo.savePath}.json`,
|
|
||||||
pretty({ ...record, exif }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
savePaths.push(relative(dir, photo.savePath));
|
|
||||||
}
|
|
||||||
await mkdir(join(dir, "albums"), { recursive: true });
|
|
||||||
await writeIfChanged(
|
|
||||||
join(dir, "albums", `${album.collectionID}.json`),
|
|
||||||
pretty({
|
|
||||||
collectionID: album.collectionID,
|
|
||||||
name: album.name,
|
|
||||||
savePaths,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { downloaded, alreadyLocal };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask for a login code on the terminal. Client.login calls this only when the
|
|
||||||
// account requires a code.
|
|
||||||
async function ask(question: string): Promise<string> {
|
|
||||||
const terminal = createInterface({ input: stdin, output: stdout });
|
|
||||||
try {
|
|
||||||
return await terminal.question(question);
|
|
||||||
} finally {
|
|
||||||
terminal.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const email = process.env.QUAK_EMAIL;
|
|
||||||
const password = process.env.QUAK_PASSWORD;
|
|
||||||
if (!email || !password) {
|
|
||||||
console.error(
|
|
||||||
"Set QUAK_EMAIL and QUAK_PASSWORD to the account's email and password.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const dir = process.argv[2] ?? "photos";
|
|
||||||
|
|
||||||
const client = await Client.login({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
totp: () => ask("Two-factor code: "),
|
|
||||||
emailOTP: () => ask("Code sent to your email: "),
|
|
||||||
});
|
|
||||||
const lib = await Library.open({
|
|
||||||
client,
|
|
||||||
downloadDirectory: dir,
|
|
||||||
// As in `quak backup`: fetch only the originals this script saves,
|
|
||||||
// not every thumbnail and the recent originals into the cache too.
|
|
||||||
precacheThumbnails: false,
|
|
||||||
precacheOriginals: false,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const { downloaded, alreadyLocal } = await downloadAlbums(lib, dir);
|
|
||||||
console.log(
|
|
||||||
`${downloaded} photos downloaded, ${alreadyLocal} already local, in ${dir}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await lib.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run main() when node runs this file, not when a test imports it. argv[1] is
|
|
||||||
// the path as given, and import.meta.url has symlinks resolved.
|
|
||||||
const script = process.argv[1];
|
|
||||||
if (script && pathToFileURL(realpathSync(script)).href === import.meta.url) {
|
|
||||||
await main();
|
|
||||||
}
|
|
||||||
+1
-1
@@ -46,7 +46,7 @@
|
|||||||
"@inquirer/prompts": "8.5.2",
|
"@inquirer/prompts": "8.5.2",
|
||||||
"commander": "14.0.3",
|
"commander": "14.0.3",
|
||||||
"env-paths": "4.0.0",
|
"env-paths": "4.0.0",
|
||||||
"exifreader": "4.46.0",
|
"exif-reader": "2.0.3",
|
||||||
"fast-srp-hap": "2.0.4",
|
"fast-srp-hap": "2.0.4",
|
||||||
"fflate": "0.8.3",
|
"fflate": "0.8.3",
|
||||||
"jpeg-js": "0.4.4",
|
"jpeg-js": "0.4.4",
|
||||||
|
|||||||
+120
-110
@@ -2,30 +2,29 @@
|
|||||||
//
|
//
|
||||||
// `lib.backup()` waits for a completed refresh of the library (a failed one
|
// `lib.backup()` waits for a completed refresh of the library (a failed one
|
||||||
// fails the backup before any file is touched), then, for every file in scope,
|
// fails the backup before any file is touched), then, for every file in scope,
|
||||||
// puts its original at its save path under `downloadDirectory`, as
|
// gets its original bytes onto disk under `downloadDirectory` and rebuilds the
|
||||||
// `Photo.download()` does, and rebuilds the derived views (per-file sidecars,
|
// derived views (per-file sidecars, per-collection symlink trees,
|
||||||
// per-collection symlink trees, per-collection JSON) from the model. The
|
// per-collection JSON) from the model. The on-disk layout is the historical
|
||||||
// on-disk layout:
|
// one:
|
||||||
//
|
//
|
||||||
// <downloadDirectory>/
|
// <downloadDirectory>/
|
||||||
// YYYY/YYYY-MM/YYYY-MM-DD/
|
// originals/<fileID>.<ext> the decrypted bytes
|
||||||
// YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path)
|
// originals/<fileID>.json per-file metadata sidecar
|
||||||
// YYYY-MM-DD.<fileID>.json per-file metadata sidecar
|
// collections/<name>/<title> symlink into ../../originals
|
||||||
// collections/<name>/<title> symlink to the original
|
|
||||||
// collections/<name>.json per-collection metadata
|
// collections/<name>.json per-collection metadata
|
||||||
// failures.json durable ledger of unresolved failures
|
// failures.json durable ledger of unresolved failures
|
||||||
//
|
//
|
||||||
// A live photo's original is its image and its video, each with its own
|
// A live photo's original is its image and its video, `<fileID>.<ext>` each
|
||||||
// extension, beside `YYYY-MM-DD.<fileID>.livephoto.json` naming them; its album
|
// with its own extension, and `originals/<fileID>.livephoto.json` naming them;
|
||||||
// folders link both.
|
// its album folders link both.
|
||||||
//
|
//
|
||||||
// Crash-safety rests on two properties. Bytes are present-means-complete: an
|
// Crash-safety rests on two properties. Bytes are present-means-complete: an
|
||||||
// original appears at its save path only via the content layer's atomic
|
// original appears under `originals/` only via the content layer's atomic
|
||||||
// temp-then-rename, so a file that exists is whole and is never re-fetched — an
|
// temp-then-rename, so a file that exists is whole and is never re-fetched — an
|
||||||
// interrupted run resumes by looking at the save paths. The derived views hold
|
// interrupted run resumes by listing the directory. The derived views hold no
|
||||||
// no unique state, so they are rebuilt every run; that repairs stale sidecars
|
// unique state, so they are rebuilt every run; that repairs stale sidecars and
|
||||||
// and missing or broken symlinks left by an earlier crash. A rebuild also
|
// missing or broken symlinks left by an earlier crash. A rebuild also removes
|
||||||
// removes the symlinks to originals that no longer belong to an album, and the
|
// the symlinks into originals/ that no longer belong to an album, and the
|
||||||
// directories of albums that no longer exist.
|
// directories of albums that no longer exist.
|
||||||
//
|
//
|
||||||
// Resilience (issue #8): no per-file condition aborts the run. A failed
|
// Resilience (issue #8): no per-file condition aborts the run. A failed
|
||||||
@@ -49,25 +48,24 @@ import {
|
|||||||
symlinkSync,
|
symlinkSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { dirname, extname, join, relative, resolve } from "node:path";
|
import { copyFile, rename, rm } from "node:fs/promises";
|
||||||
|
import { basename, dirname, extname, join, relative } from "node:path";
|
||||||
|
|
||||||
import { removeLeftoverTempFiles } from "./download/index.js";
|
import { fsyncPath, removeLeftoverTempFiles } from "./download/index.js";
|
||||||
import { sanitizeFileName, withExtension } from "./filename.js";
|
import { sanitizeFileName, withExtension } from "./filename.js";
|
||||||
import {
|
import {
|
||||||
copyAtomic,
|
nameInOriginals,
|
||||||
placeOriginal,
|
storedOriginal,
|
||||||
savePath,
|
writeLivePhotoJSON,
|
||||||
storedAtSavePath,
|
|
||||||
} from "./library/content.js";
|
} from "./library/content.js";
|
||||||
import { representative } from "./library/records.js";
|
|
||||||
import type { Collection, EnteFile } from "./model/types.js";
|
import type { Collection, EnteFile } from "./model/types.js";
|
||||||
|
|
||||||
export type ProgressCallback = (message: string) => void;
|
export type ProgressCallback = (message: string) => void;
|
||||||
|
|
||||||
export interface BackupOptions {
|
export interface BackupOptions {
|
||||||
// Where the backup tree lives. `lib.backup()` defaults it to the library's
|
// Where the backup tree lives. Required: with none, `backup()` throws
|
||||||
// download directory; `runBackup` with none throws before any network
|
// before any network traffic. A library opened with a `downloadDirectory`
|
||||||
// traffic.
|
// supplies the default.
|
||||||
downloadDirectory?: string;
|
downloadDirectory?: string;
|
||||||
// Fetch and store full-resolution originals. Default true.
|
// Fetch and store full-resolution originals. Default true.
|
||||||
includeOriginals?: boolean;
|
includeOriginals?: boolean;
|
||||||
@@ -91,7 +89,7 @@ export interface BackupResult {
|
|||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
// Originals fetched (or copied from the cache) this run.
|
// Originals fetched (or copied from the cache) this run.
|
||||||
downloaded: number;
|
downloaded: number;
|
||||||
// Originals already at their save path and left untouched.
|
// Originals already present and left untouched.
|
||||||
skipped: number;
|
skipped: number;
|
||||||
// Files with an unresolved failure after this run (the ledger size); the
|
// Files with an unresolved failure after this run (the ledger size); the
|
||||||
// CLI exits non-zero while this is above zero. A file can be both
|
// CLI exits non-zero while this is above zero. A file can be both
|
||||||
@@ -109,8 +107,8 @@ export interface BackupLibrary {
|
|||||||
listFiles(collectionID: number): EnteFile[];
|
listFiles(collectionID: number): EnteFile[];
|
||||||
// Get an original's bytes onto disk through the content cache/pools,
|
// Get an original's bytes onto disk through the content cache/pools,
|
||||||
// returning where they landed: `destination` when they were fetched now,
|
// returning where they landed: `destination` when they were fetched now,
|
||||||
// otherwise wherever they already were (the cache, or the library's save
|
// otherwise wherever they already were (the cache, or a prior backup). A
|
||||||
// path). A live photo lands as its image and its video, fetched now beside
|
// live photo lands as its image and its video, fetched now beside
|
||||||
// `destination`.
|
// `destination`.
|
||||||
original(
|
original(
|
||||||
fileID: number,
|
fileID: number,
|
||||||
@@ -170,6 +168,58 @@ const classify = (err: unknown): FailureClass => {
|
|||||||
const errorMessage = (err: unknown): string =>
|
const errorMessage = (err: unknown): string =>
|
||||||
err instanceof Error ? err.message : String(err);
|
err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
|
||||||
|
// `dest` appears only once it is whole ("present means complete"). As in the
|
||||||
|
// download writer, the temp file is fsynced before the rename and the directory
|
||||||
|
// after it, so a power cut cannot leave a correctly named but short original.
|
||||||
|
// The temp name carries this process's ID so a later run can tell a leftover
|
||||||
|
// from a copy still in progress (see `removeLeftoverTempFiles`).
|
||||||
|
const copyAtomic = async (src: string, dest: string): Promise<void> => {
|
||||||
|
if (src === dest) return;
|
||||||
|
const tmp = join(
|
||||||
|
dirname(dest),
|
||||||
|
`.quak-backup-${basename(dest)}-${process.pid}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2)}.tmp`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await copyFile(src, tmp);
|
||||||
|
await fsyncPath(tmp);
|
||||||
|
// `rename` replaces the destination's directory entry: an existing
|
||||||
|
// symlink at `dest` is replaced, not followed, and the new file has
|
||||||
|
// the temp file's permissions (copied from `src`).
|
||||||
|
await rename(tmp, dest);
|
||||||
|
await fsyncPath(dirname(dest));
|
||||||
|
} finally {
|
||||||
|
await rm(tmp, { force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Put an original the library returned at `dest` in originals/, where a fresh
|
||||||
|
// fetch already wrote it. A live photo's image and video go beside `dest`: when
|
||||||
|
// they came from the cache they are copied, after removing whatever was at
|
||||||
|
// `dest` (an earlier version's ZIP of the two). Then the JSON file naming them
|
||||||
|
// is written, which is what makes the live photo count as stored.
|
||||||
|
const placeOriginal = async (
|
||||||
|
file: EnteFile,
|
||||||
|
dest: string,
|
||||||
|
got: { path: string; videoPath?: string },
|
||||||
|
): Promise<void> => {
|
||||||
|
if (got.videoPath === undefined) {
|
||||||
|
await copyAtomic(got.path, dest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const originalsDir = dirname(dest);
|
||||||
|
const path = join(originalsDir, basename(got.path));
|
||||||
|
const videoPath = join(originalsDir, basename(got.videoPath));
|
||||||
|
if (got.path !== path) {
|
||||||
|
await rm(dest, { force: true });
|
||||||
|
await copyAtomic(got.path, path);
|
||||||
|
await copyAtomic(got.videoPath, videoPath);
|
||||||
|
}
|
||||||
|
await writeLivePhotoJSON(originalsDir, file.id, { path, videoPath });
|
||||||
|
};
|
||||||
|
|
||||||
// Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or
|
// Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or
|
||||||
// non-symlink entry. Throws on failure (a directory in the way, no permission)
|
// non-symlink entry. Throws on failure (a directory in the way, no permission)
|
||||||
// so the caller records it and moves on rather than aborting the run.
|
// so the caller records it and moves on rather than aborting the run.
|
||||||
@@ -241,55 +291,36 @@ const linksFor = (
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Every date folder (`YYYY/YYYY-MM/YYYY-MM-DD/`) under `root`, whether or not a
|
// Remove the symlinks in the album directory `dir` that point into
|
||||||
// file in this backup is saved there. A folder that cannot be read is skipped.
|
// `originalsDir` and are not named in `keep`. Nothing else in the directory
|
||||||
const dateFolders = (root: string): string[] => {
|
// is touched: anything else there was put there by the user.
|
||||||
const subfolders = (dir: string, name: RegExp): string[] => {
|
|
||||||
try {
|
|
||||||
return readdirSync(dir, { withFileTypes: true })
|
|
||||||
.filter((e) => e.isDirectory() && name.test(e.name))
|
|
||||||
.map((e) => join(dir, e.name));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return subfolders(root, /^\d{4}$/)
|
|
||||||
.flatMap((year) => subfolders(year, /^\d{4}-\d\d$/))
|
|
||||||
.flatMap((month) => subfolders(month, /^\d{4}-\d\d-\d\d$/));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Whether the entry at `path` is a symlink a backup to `root` made: one to an
|
|
||||||
// original in a `YYYY/YYYY-MM/YYYY-MM-DD/` folder of `root`.
|
|
||||||
const linksToOriginal = (path: string, root: string): boolean => {
|
|
||||||
if (!lstatSync(path).isSymbolicLink()) return false;
|
|
||||||
const target = relative(root, resolve(dirname(path), readlinkSync(path)));
|
|
||||||
return /^\d{4}\/\d{4}-\d\d\/\d{4}-\d\d-\d\d\/[^/]+$/.test(target);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove the symlinks in the album directory `dir` that point to an original
|
|
||||||
// in `root` and are not named in `keep`. Nothing else in the directory is
|
|
||||||
// touched: anything else there was put there by the user.
|
|
||||||
const removeStaleLinks = (
|
const removeStaleLinks = (
|
||||||
dir: string,
|
dir: string,
|
||||||
keep: Set<string>,
|
keep: Set<string>,
|
||||||
root: string,
|
originalsDir: string,
|
||||||
): void => {
|
): void => {
|
||||||
|
const target = relative(dir, originalsDir);
|
||||||
for (const name of readdirSync(dir)) {
|
for (const name of readdirSync(dir)) {
|
||||||
if (keep.has(name)) continue;
|
if (keep.has(name)) continue;
|
||||||
const path = join(dir, name);
|
const path = join(dir, name);
|
||||||
if (linksToOriginal(path, root)) rmSync(path);
|
if (
|
||||||
|
lstatSync(path).isSymbolicLink() &&
|
||||||
|
dirname(readlinkSync(path)) === target
|
||||||
|
) {
|
||||||
|
rmSync(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove the directories under `collectionsDir` that an earlier run wrote for
|
// Remove the directories under `collectionsDir` that an earlier run wrote for
|
||||||
// an album that is gone or renamed: a directory not named in `current` with a
|
// an album that is gone or renamed: a directory not named in `current` with a
|
||||||
// `<name>.json` beside it holding an album ID, which is what a run writes. Its
|
// `<name>.json` beside it holding an album ID, which is what a run writes. Its
|
||||||
// symlinks to originals in `root` are removed; if that leaves it empty, it and
|
// symlinks into originals/ are removed; if that leaves it empty, it and its
|
||||||
// its JSON are deleted, otherwise both stay for what the user put there.
|
// JSON are deleted, otherwise both stay for what the user put there.
|
||||||
const removeStaleAlbumDirs = (
|
const removeStaleAlbumDirs = (
|
||||||
collectionsDir: string,
|
collectionsDir: string,
|
||||||
current: Set<string>,
|
current: Set<string>,
|
||||||
root: string,
|
originalsDir: string,
|
||||||
): void => {
|
): void => {
|
||||||
for (const entry of readdirSync(collectionsDir, { withFileTypes: true })) {
|
for (const entry of readdirSync(collectionsDir, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory() || current.has(entry.name)) continue;
|
if (!entry.isDirectory() || current.has(entry.name)) continue;
|
||||||
@@ -303,7 +334,7 @@ const removeStaleAlbumDirs = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const dir = join(collectionsDir, entry.name);
|
const dir = join(collectionsDir, entry.name);
|
||||||
removeStaleLinks(dir, new Set(), root);
|
removeStaleLinks(dir, new Set(), originalsDir);
|
||||||
if (readdirSync(dir).length > 0) continue;
|
if (readdirSync(dir).length > 0) continue;
|
||||||
rmdirSync(dir);
|
rmdirSync(dir);
|
||||||
rmSync(jsonPath);
|
rmSync(jsonPath);
|
||||||
@@ -371,48 +402,34 @@ export const runBackup = async (
|
|||||||
log("Refreshing library...");
|
log("Refreshing library...");
|
||||||
await lib.refresh();
|
await lib.refresh();
|
||||||
|
|
||||||
|
const originalsDir = join(downloadDirectory, "originals");
|
||||||
const collectionsDir = join(downloadDirectory, "collections");
|
const collectionsDir = join(downloadDirectory, "collections");
|
||||||
const thumbnailsDir = join(downloadDirectory, "thumbnails");
|
const thumbnailsDir = join(downloadDirectory, "thumbnails");
|
||||||
|
mkdirSync(originalsDir, { recursive: true });
|
||||||
mkdirSync(collectionsDir, { recursive: true });
|
mkdirSync(collectionsDir, { recursive: true });
|
||||||
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
|
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
|
||||||
|
removeLeftoverTempFiles(originalsDir);
|
||||||
removeLeftoverTempFiles(thumbnailsDir);
|
removeLeftoverTempFiles(thumbnailsDir);
|
||||||
for (const dir of dateFolders(downloadDirectory)) {
|
|
||||||
removeLeftoverTempFiles(dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ledgerPath = join(downloadDirectory, "failures.json");
|
const ledgerPath = join(downloadDirectory, "failures.json");
|
||||||
const ledger = loadLedger(ledgerPath);
|
const ledger = loadLedger(ledgerPath);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Collections in scope, and the distinct files across them (a file shared
|
// Collections in scope, and the distinct files across them (a file shared
|
||||||
// by two albums is one original). Each file is the membership
|
// by two albums is one original).
|
||||||
// `representative` picks from all of its albums, in scope or not, so it is
|
|
||||||
// saved at the path `photo.savePath` names.
|
|
||||||
const allCollections = lib.listCollections();
|
const allCollections = lib.listCollections();
|
||||||
const collections = allCollections.filter((c) =>
|
const collections = allCollections.filter((c) =>
|
||||||
only ? only.has(c.name) : true,
|
only ? only.has(c.name) : true,
|
||||||
);
|
);
|
||||||
const collectionName = new Map<number, string>();
|
const collectionName = new Map<number, string>();
|
||||||
for (const c of allCollections) collectionName.set(c.id, c.name);
|
for (const c of collections) collectionName.set(c.id, c.name);
|
||||||
|
|
||||||
const memberships = new Map<number, EnteFile[]>();
|
const distinct = new Map<number, EnteFile>();
|
||||||
const filesByCollection = new Map<number, EnteFile[]>();
|
const filesByCollection = new Map<number, EnteFile[]>();
|
||||||
for (const c of allCollections) {
|
for (const c of collections) {
|
||||||
const files = lib.listFiles(c.id);
|
const files = lib.listFiles(c.id);
|
||||||
filesByCollection.set(c.id, files);
|
filesByCollection.set(c.id, files);
|
||||||
for (const f of files) {
|
for (const f of files) if (!distinct.has(f.id)) distinct.set(f.id, f);
|
||||||
const arr = memberships.get(f.id);
|
|
||||||
if (arr) arr.push(f);
|
|
||||||
else memberships.set(f.id, [f]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const distinct = new Map<number, EnteFile>();
|
|
||||||
for (const c of collections) {
|
|
||||||
for (const f of filesByCollection.get(c.id)!) {
|
|
||||||
if (!distinct.has(f.id)) {
|
|
||||||
distinct.set(f.id, representative(memberships.get(f.id)!));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const errors: BackupError[] = [];
|
const errors: BackupError[] = [];
|
||||||
@@ -448,22 +465,25 @@ export const runBackup = async (
|
|||||||
failedThisRun.add(file.id);
|
failedThisRun.add(file.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Phase 1: get the bytes. Put each pending original at its save path
|
// Phase 1: get the bytes. Fetch each pending original (and optional
|
||||||
// through the content cache/pools, as `Photo.download()` does, and fetch
|
// thumbnail) through the content cache/pools and place it under the backup
|
||||||
// the optional thumbnails; a present file is left as is.
|
// tree; a present file is left as is.
|
||||||
if (includeOriginals) {
|
if (includeOriginals) {
|
||||||
for (const [fileID, file] of distinct) {
|
for (const [fileID, file] of distinct) {
|
||||||
if (storedAtSavePath(downloadDirectory, file) !== undefined) {
|
if (storedOriginal(originalsDir, file) !== undefined) {
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const dest = join(originalsDir, nameInOriginals(file));
|
||||||
try {
|
try {
|
||||||
log(`Fetching original ${file.metadata.title} (${fileID})...`);
|
log(`Fetching original ${file.metadata.title} (${fileID})...`);
|
||||||
// A fetched original is written straight to its save path (a
|
// A fetched original is written straight to `dest` (a live
|
||||||
// live photo beside it); only one that was already cached
|
// photo beside it); only one that was already cached elsewhere
|
||||||
// elsewhere is copied.
|
// is copied.
|
||||||
await placeOriginal(downloadDirectory, file, (dest) =>
|
await placeOriginal(
|
||||||
lib.original(fileID, dest),
|
file,
|
||||||
|
dest,
|
||||||
|
await lib.original(fileID, dest),
|
||||||
);
|
);
|
||||||
downloaded++;
|
downloaded++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -499,10 +519,9 @@ export const runBackup = async (
|
|||||||
// Phase 2: rebuild the derived views from the model. Sidecars first, for
|
// Phase 2: rebuild the derived views from the model. Sidecars first, for
|
||||||
// every present original (this repairs stale ones).
|
// every present original (this repairs stale ones).
|
||||||
if (includeOriginals) {
|
if (includeOriginals) {
|
||||||
for (const file of distinct.values()) {
|
for (const [fileID, file] of distinct) {
|
||||||
if (storedAtSavePath(downloadDirectory, file) !== undefined) {
|
if (storedOriginal(originalsDir, file) !== undefined) {
|
||||||
const path = savePath(downloadDirectory, file);
|
writeSidecar(join(originalsDir, `${fileID}.json`), file);
|
||||||
writeSidecar(withExtension(path, ".json"), file);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,11 +543,7 @@ export const runBackup = async (
|
|||||||
allCollections.map((c, i) => [c.id, dirNames[i]!]),
|
allCollections.map((c, i) => [c.id, dirNames[i]!]),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
removeStaleAlbumDirs(
|
removeStaleAlbumDirs(collectionsDir, new Set(dirNames), originalsDir);
|
||||||
collectionsDir,
|
|
||||||
new Set(dirNames),
|
|
||||||
downloadDirectory,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`FAILED removing old album directories: ${errorMessage(err)}`);
|
log(`FAILED removing old album directories: ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
@@ -538,18 +553,13 @@ export const runBackup = async (
|
|||||||
const colDir = join(collectionsDir, colDirName);
|
const colDir = join(collectionsDir, colDirName);
|
||||||
mkdirSync(colDir, { recursive: true });
|
mkdirSync(colDir, { recursive: true });
|
||||||
|
|
||||||
// Every album links the one original, saved from the file's entry in
|
|
||||||
// `distinct`.
|
|
||||||
const files = filesByCollection.get(c.id) ?? [];
|
const files = filesByCollection.get(c.id) ?? [];
|
||||||
const links = files.flatMap((f) =>
|
const links = files.flatMap((f) =>
|
||||||
linksFor(
|
linksFor(f, storedOriginal(originalsDir, f)),
|
||||||
f,
|
|
||||||
storedAtSavePath(downloadDirectory, distinct.get(f.id)!),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const linkNames = uniqueNames(links, true);
|
const linkNames = uniqueNames(links, true);
|
||||||
try {
|
try {
|
||||||
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
|
removeStaleLinks(colDir, new Set(linkNames), originalsDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`);
|
log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -361,8 +361,8 @@ const openPart = async (
|
|||||||
// written unpacked: each part is named `destination` with the extension
|
// written unpacked: each part is named `destination` with the extension
|
||||||
// replaced by its own entry's, and the two must differ ignoring case. When the
|
// replaced by its own entry's, and the two must differ ignoring case. When the
|
||||||
// file records a hash, `<imageHash>:<videoHash>` must match it, each over that
|
// file records a hash, `<imageHash>:<videoHash>` must match it, each over that
|
||||||
// part's own bytes. Only then are the image, then the video, renamed into
|
// part's own bytes. Only then is whatever was at `destination` removed and the
|
||||||
// place; on any failure neither is stored.
|
// image, then the video, renamed into place; on any failure neither is stored.
|
||||||
//
|
//
|
||||||
// The ZIP is chosen by its uploader and may expand enormously, so each part is
|
// The ZIP is chosen by its uploader and may expand enormously, so each part is
|
||||||
// written as it decompresses and never held, and the ZIP is refused once the
|
// written as it decompresses and never held, and the ZIP is refused once the
|
||||||
@@ -486,6 +486,7 @@ const decryptLivePhoto = async (
|
|||||||
await part.handle.sync();
|
await part.handle.sync();
|
||||||
await part.handle.close();
|
await part.handle.close();
|
||||||
}
|
}
|
||||||
|
await rm(destination, { force: true });
|
||||||
await rename(image.tmpPath, path);
|
await rename(image.tmpPath, path);
|
||||||
try {
|
try {
|
||||||
await rename(video.tmpPath, videoPath);
|
await rename(video.tmpPath, videoPath);
|
||||||
@@ -569,7 +570,8 @@ const fetchAndDecrypt = async (
|
|||||||
}, api.getRetryOptions());
|
}, api.getRetryOptions());
|
||||||
|
|
||||||
// Write `file`'s original to `outPath`. A live photo is written as its image
|
// Write `file`'s original to `outPath`. A live photo is written as its image
|
||||||
// and its video beside `outPath` instead (see `decryptLivePhoto`).
|
// and its video beside `outPath` instead, and whatever was at `outPath` is
|
||||||
|
// removed (see `decryptLivePhoto`).
|
||||||
export const downloadFile = async (
|
export const downloadFile = async (
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
file: EnteFile,
|
file: EnteFile,
|
||||||
|
|||||||
-164
@@ -1,164 +0,0 @@
|
|||||||
// EXIF in an original's bytes, read with exifreader, which reads it from JPEG,
|
|
||||||
// HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports.
|
|
||||||
// `backup-metadata --exif` records every EXIF tag it finds except the
|
|
||||||
// thumbnail's. `Photo.exif()` returns every tag, the thumbnail's included, and
|
|
||||||
// `Photo`'s typed methods return the common fields picked from them here.
|
|
||||||
|
|
||||||
import ExifReader, { type ExpandedTags } from "exifreader";
|
|
||||||
|
|
||||||
// The EXIF tags in `bytes` (`exif`), the embedded thumbnail's tags
|
|
||||||
// (`Thumbnail`), and where the EXIF block lies in `bytes` (`metadataRange`).
|
|
||||||
// Undefined when exifreader cannot read the file at all, such as a video. An
|
|
||||||
// EXIF block it finds but reads no tag from comes back as an empty `exif`. A
|
|
||||||
// tag exifreader has no name for is keyed `undefined-<tag number>`. Each tag's
|
|
||||||
// `computed` holds its value as a string or number, or as an array of them for
|
|
||||||
// a tag with several values, such as `GPSLatitude`'s `[40, 26, 46]`. A
|
|
||||||
// fraction with a zero denominator computes to null.
|
|
||||||
export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
|
|
||||||
try {
|
|
||||||
return ExifReader.loadView(
|
|
||||||
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength),
|
|
||||||
{
|
|
||||||
expanded: true,
|
|
||||||
computed: true,
|
|
||||||
includeOffsets: true,
|
|
||||||
includeUnknown: true,
|
|
||||||
includeTags: { exif: true, thumbnail: true },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Every EXIF tag of an original, keyed by name, each as exifreader decodes it
|
|
||||||
// (see `readExifTags`). The embedded thumbnail's own tags are under
|
|
||||||
// `Thumbnail`, so its `Orientation` or `ImageWidth` cannot hide the main
|
|
||||||
// image's.
|
|
||||||
export type ExifTags = Omit<NonNullable<ExpandedTags["exif"]>, "Thumbnail"> & {
|
|
||||||
Thumbnail?: Omit<
|
|
||||||
NonNullable<ExpandedTags["Thumbnail"]>,
|
|
||||||
"type" | "image" | "base64"
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Every EXIF tag in `bytes`: `{}` when the file has no EXIF, exifreader cannot
|
|
||||||
// read its EXIF, or it is not an image exifreader reads.
|
|
||||||
export const readAllExifTags = (bytes: Uint8Array): ExifTags => {
|
|
||||||
const tags = readExifTags(bytes);
|
|
||||||
if (!tags?.Thumbnail) return tags?.exif ?? {};
|
|
||||||
// exifreader puts the thumbnail's JPEG image beside its tags, as `type`,
|
|
||||||
// `image` and `base64`. The image is not a tag, so it is left out.
|
|
||||||
const {
|
|
||||||
type: _type,
|
|
||||||
image: _image,
|
|
||||||
base64: _base64,
|
|
||||||
...thumbnail
|
|
||||||
} = tags.Thumbnail;
|
|
||||||
return { ...tags.exif, Thumbnail: thumbnail };
|
|
||||||
};
|
|
||||||
|
|
||||||
// The common EXIF fields of an original, one for each of `Photo`'s typed
|
|
||||||
// methods. Each is absent when the file lacks it.
|
|
||||||
export interface PhotoExif {
|
|
||||||
make?: string;
|
|
||||||
model?: string;
|
|
||||||
lensModel?: string;
|
|
||||||
// When the photo was taken, by the camera's clock. EXIF writes this as text
|
|
||||||
// with no time zone, and it is read as if it were UTC: the Date's UTC
|
|
||||||
// fields are the clock reading, which is the moment it was taken only when
|
|
||||||
// the clock was set to UTC.
|
|
||||||
dateTimeOriginal?: Date;
|
|
||||||
// The camera clock's offset from UTC, such as "+02:00".
|
|
||||||
offsetTimeOriginal?: string;
|
|
||||||
// Seconds.
|
|
||||||
exposureTime?: number;
|
|
||||||
fNumber?: number;
|
|
||||||
iso?: number;
|
|
||||||
// Millimetres.
|
|
||||||
focalLength?: number;
|
|
||||||
// The EXIF orientation code, 1 to 8.
|
|
||||||
orientation?: number;
|
|
||||||
// Decimal degrees, negative south of the equator and west of Greenwich.
|
|
||||||
gpsLatitude?: number;
|
|
||||||
gpsLongitude?: number;
|
|
||||||
// Metres, negative below sea level.
|
|
||||||
gpsAltitude?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// exifreader gives "<faulty value>" for a tag whose value lies outside the
|
|
||||||
// file; that tag is left out like one the file lacks.
|
|
||||||
const asString = (v: unknown): string | undefined =>
|
|
||||||
typeof v === "string" && v.length > 0 && v !== "<faulty value>"
|
|
||||||
? v
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const asNumber = (v: unknown): number | undefined =>
|
|
||||||
typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
||||||
|
|
||||||
// EXIF writes a date and time as "2021:07:15 14:30:00". This is that reading
|
|
||||||
// in a Date's UTC fields.
|
|
||||||
const asDate = (v: unknown): Date | undefined => {
|
|
||||||
const m =
|
|
||||||
typeof v === "string"
|
|
||||||
? /^(\d{4}):(\d{2}):(\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(v)
|
|
||||||
: null;
|
|
||||||
if (!m) return undefined;
|
|
||||||
const date = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}Z`);
|
|
||||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
||||||
};
|
|
||||||
|
|
||||||
// GPSLatitude and GPSLongitude hold degrees, minutes and seconds, computed as
|
|
||||||
// three numbers. This is them in decimal degrees, negative when `ref`, the
|
|
||||||
// GPSLatitudeRef or GPSLongitudeRef tag, is `negativeRef` ("S" or "W").
|
|
||||||
// Without that tag the hemisphere is unknown, so it is undefined.
|
|
||||||
const asDegrees = (
|
|
||||||
dms: unknown,
|
|
||||||
ref: unknown,
|
|
||||||
negativeRef: string,
|
|
||||||
): number | undefined => {
|
|
||||||
if (!Array.isArray(dms) || ref === undefined) return undefined;
|
|
||||||
const [d, m, s] = dms.map(asNumber);
|
|
||||||
if (d === undefined || m === undefined || s === undefined) return undefined;
|
|
||||||
const degrees = d + m / 60 + s / 3600;
|
|
||||||
return ref === negativeRef ? -degrees : degrees;
|
|
||||||
};
|
|
||||||
|
|
||||||
// The common fields picked from an original's EXIF tags, `readAllExifTags`'s
|
|
||||||
// result: `{}` when there are none.
|
|
||||||
export const readPhotoExif = (tags: ExifTags): PhotoExif => {
|
|
||||||
const altitude = asNumber(tags.GPSAltitude?.computed);
|
|
||||||
const fields: PhotoExif = {
|
|
||||||
make: asString(tags.Make?.computed),
|
|
||||||
model: asString(tags.Model?.computed),
|
|
||||||
lensModel: asString(tags.LensModel?.computed),
|
|
||||||
dateTimeOriginal: asDate(tags.DateTimeOriginal?.computed),
|
|
||||||
offsetTimeOriginal: asString(tags.OffsetTimeOriginal?.computed),
|
|
||||||
exposureTime: asNumber(tags.ExposureTime?.computed),
|
|
||||||
fNumber: asNumber(tags.FNumber?.computed),
|
|
||||||
// Only when the tag holds a single number, as most cameras write it.
|
|
||||||
iso: asNumber(tags.ISOSpeedRatings?.computed),
|
|
||||||
focalLength: asNumber(tags.FocalLength?.computed),
|
|
||||||
orientation: asNumber(tags.Orientation?.computed),
|
|
||||||
gpsLatitude: asDegrees(
|
|
||||||
tags.GPSLatitude?.computed,
|
|
||||||
tags.GPSLatitudeRef?.computed,
|
|
||||||
"S",
|
|
||||||
),
|
|
||||||
gpsLongitude: asDegrees(
|
|
||||||
tags.GPSLongitude?.computed,
|
|
||||||
tags.GPSLongitudeRef?.computed,
|
|
||||||
"W",
|
|
||||||
),
|
|
||||||
// A GPSAltitudeRef of 1 means the altitude is below sea level.
|
|
||||||
gpsAltitude:
|
|
||||||
altitude !== undefined && tags.GPSAltitudeRef?.value === 1
|
|
||||||
? -altitude
|
|
||||||
: altitude,
|
|
||||||
};
|
|
||||||
// Leave out what the file lacks, so a missing field is absent rather than
|
|
||||||
// present and undefined.
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(fields).filter(([, v]) => v !== undefined),
|
|
||||||
) as PhotoExif;
|
|
||||||
};
|
|
||||||
@@ -53,7 +53,6 @@ export {
|
|||||||
type PhotoFilter,
|
type PhotoFilter,
|
||||||
type TimelineGroup,
|
type TimelineGroup,
|
||||||
type GroupBy,
|
type GroupBy,
|
||||||
type SavePathLookup,
|
|
||||||
type ContentSource,
|
type ContentSource,
|
||||||
type ContentResult,
|
type ContentResult,
|
||||||
type ContentEvent,
|
type ContentEvent,
|
||||||
@@ -85,7 +84,6 @@ export type {
|
|||||||
LibrarySnapshot,
|
LibrarySnapshot,
|
||||||
LibraryChange,
|
LibraryChange,
|
||||||
} from "./library/records.js";
|
} from "./library/records.js";
|
||||||
export type { ExifTags, PhotoExif } from "./exif.js";
|
|
||||||
export { decryptCollection, decryptFile } from "./model/index.js";
|
export { decryptCollection, decryptFile } from "./model/index.js";
|
||||||
export { downloadFile, downloadThumbnail } from "./download/index.js";
|
export { downloadFile, downloadThumbnail } from "./download/index.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
+79
-159
@@ -23,19 +23,19 @@
|
|||||||
// original with no recorded hash is stored unchecked, as the upstream client
|
// original with no recorded hash is stored unchecked, as the upstream client
|
||||||
// does; thumbnails have none. On top of that this module refuses to record a
|
// does; thumbnails have none. On top of that this module refuses to record a
|
||||||
// stored file that came out empty.
|
// stored file that came out empty.
|
||||||
//
|
|
||||||
// It also names each original's save path under the download directory
|
|
||||||
// (`savePath`), where `Photo.download()` and `lib.backup()` put it. A copy in
|
|
||||||
// the cache does not count as saved there, but is copied there rather than
|
|
||||||
// fetched again.
|
|
||||||
|
|
||||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
import {
|
||||||
|
closeSync,
|
||||||
|
existsSync,
|
||||||
|
openSync,
|
||||||
|
readFileSync,
|
||||||
|
readSync,
|
||||||
|
statSync,
|
||||||
|
} from "node:fs";
|
||||||
import {
|
import {
|
||||||
chmod,
|
chmod,
|
||||||
copyFile,
|
|
||||||
mkdir,
|
mkdir,
|
||||||
readdir,
|
readdir,
|
||||||
rename,
|
|
||||||
rm,
|
rm,
|
||||||
stat,
|
stat,
|
||||||
statfs,
|
statfs,
|
||||||
@@ -47,15 +47,13 @@ import type { ApiClient } from "../api/client.js";
|
|||||||
import {
|
import {
|
||||||
downloadFile,
|
downloadFile,
|
||||||
downloadThumbnail,
|
downloadThumbnail,
|
||||||
fsyncPath,
|
|
||||||
type ProgressCallback,
|
type ProgressCallback,
|
||||||
removeLeftoverTempFiles,
|
removeLeftoverTempFiles,
|
||||||
writeAtomic,
|
writeAtomic,
|
||||||
} from "../download/index.js";
|
} from "../download/index.js";
|
||||||
import { safeExtension, withExtension } from "../filename.js";
|
import { safeExtension } from "../filename.js";
|
||||||
import type { EnteFile } from "../model/types.js";
|
import type { EnteFile } from "../model/types.js";
|
||||||
import type { Priority, RequestPools } from "./pools.js";
|
import type { Priority, RequestPools } from "./pools.js";
|
||||||
import { takenAtOf } from "./records.js";
|
|
||||||
|
|
||||||
const DIR_MODE = 0o700;
|
const DIR_MODE = 0o700;
|
||||||
const FILE_MODE = 0o600;
|
const FILE_MODE = 0o600;
|
||||||
@@ -110,9 +108,6 @@ export interface ContentOptions {
|
|||||||
export interface PhotoContent {
|
export interface PhotoContent {
|
||||||
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
||||||
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
||||||
// Put the original at the save path of `file`, the copy the `Photo` holds,
|
|
||||||
// and return it there.
|
|
||||||
download(file: EnteFile): Promise<ContentResult>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnsureResult {
|
export interface EnsureResult {
|
||||||
@@ -199,10 +194,10 @@ export interface ContentCacheOptions {
|
|||||||
pools: RequestPools;
|
pools: RequestPools;
|
||||||
source: ContentSource;
|
source: ContentSource;
|
||||||
cacheDirectory: string;
|
cacheDirectory: string;
|
||||||
// The root of the save paths. An original already stored at its save path
|
// The backup destination (issue-level `downloadDirectory`). An original
|
||||||
// counts as present, so the cache serves it rather than fetching a second
|
// already stored there by a backup counts as present, so the cache serves
|
||||||
// copy.
|
// it rather than fetching a second copy.
|
||||||
downloadDirectory: string;
|
downloadDirectory?: string;
|
||||||
// Resolve any membership of a file; every membership shares the underlying
|
// Resolve any membership of a file; every membership shares the underlying
|
||||||
// content key, so any one decrypts the same bytes.
|
// content key, so any one decrypts the same bytes.
|
||||||
getFile: (fileID: number) => EnteFile | undefined;
|
getFile: (fileID: number) => EnteFile | undefined;
|
||||||
@@ -229,27 +224,11 @@ class AbortDrop extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The name of a file's original in the cache's originals/: `<fileID><ext>`, the
|
// The name of a file's original in originals/: `<fileID><ext>`, the extension
|
||||||
// extension taken from the title (or `.bin`).
|
// taken from the title (or `.bin`). A backup names its originals the same way.
|
||||||
export const nameInOriginals = (file: EnteFile): string =>
|
export const nameInOriginals = (file: EnteFile): string =>
|
||||||
`${file.id}${safeExtension(file.metadata.title)}`;
|
`${file.id}${safeExtension(file.metadata.title)}`;
|
||||||
|
|
||||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
||||||
|
|
||||||
// Where the original of `file` is saved under `root`:
|
|
||||||
// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`, dated by the photo's
|
|
||||||
// `takenAt` in this machine's time zone, with the extension taken from the
|
|
||||||
// title (or `.bin`). A live photo is stored as its image and its video beside
|
|
||||||
// this path, each with the extension found inside the live photo.
|
|
||||||
export const savePath = (root: string, file: EnteFile): string => {
|
|
||||||
const taken = new Date(takenAtOf(file));
|
|
||||||
const year = String(taken.getFullYear());
|
|
||||||
const month = `${year}-${pad(taken.getMonth() + 1)}`;
|
|
||||||
const day = `${month}-${pad(taken.getDate())}`;
|
|
||||||
const ext = safeExtension(file.metadata.title);
|
|
||||||
return join(root, year, month, day, `${day}.${file.id}${ext}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The fileID a cache filename encodes, or undefined when the name is not one
|
// The fileID a cache filename encodes, or undefined when the name is not one
|
||||||
// the cache writes (`<digits><ext>`).
|
// the cache writes (`<digits><ext>`).
|
||||||
const fileIDFromName = (name: string): number | undefined => {
|
const fileIDFromName = (name: string): number | undefined => {
|
||||||
@@ -274,31 +253,49 @@ const fileSize = (path: string): number | undefined => {
|
|||||||
const hasContent = (path: string | undefined): boolean =>
|
const hasContent = (path: string | undefined): boolean =>
|
||||||
path !== undefined && (fileSize(path) ?? 0) > 0;
|
path !== undefined && (fileSize(path) ?? 0) > 0;
|
||||||
|
|
||||||
|
// Whether the file at `path` begins as a ZIP does, with `PK\x03\x04`. False
|
||||||
|
// when it cannot be read.
|
||||||
|
const isZip = (path: string): boolean => {
|
||||||
|
try {
|
||||||
|
const fd = openSync(path, "r");
|
||||||
|
try {
|
||||||
|
const head = Buffer.alloc(4);
|
||||||
|
return (
|
||||||
|
readSync(fd, head, 0, 4, 0) === 4 &&
|
||||||
|
head.toString("latin1") === "PK\x03\x04"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// A live photo's image and video are named with the extensions from inside its
|
// A live photo's image and video are named with the extensions from inside its
|
||||||
// ZIP, so their names alone do not say which is which. Wherever the cache or a
|
// ZIP, so their names alone do not say which is which. Wherever the cache or a
|
||||||
// save path stores one, a JSON file of this name beside them names both.
|
// backup stores one, a JSON file of this name beside them names both.
|
||||||
// `name` is the original's name without its extension: `<fileID>` in the
|
const livePhotoJSONName = (fileID: number): string =>
|
||||||
// cache, `YYYY-MM-DD.<fileID>` at the save path.
|
`${fileID}.livephoto.json`;
|
||||||
const livePhotoJSONName = (name: string): string => `${name}.livephoto.json`;
|
|
||||||
|
|
||||||
// The image and video that the live photo's JSON file in `dir` names, or
|
// The image and video that the live photo's JSON file in `dir` names, or
|
||||||
// undefined when there is none. Only `name` with an extension is taken, so the
|
// undefined when there is none. Only names of the form the cache writes are
|
||||||
// file cannot point outside `dir`.
|
// taken, so the file cannot point outside `dir`.
|
||||||
const readLivePhotoJSON = (
|
const readLivePhotoJSON = (
|
||||||
dir: string,
|
dir: string,
|
||||||
name: string,
|
fileID: number,
|
||||||
): { path: string; videoPath: string } | undefined => {
|
): { path: string; videoPath: string } | undefined => {
|
||||||
const valid = (part: unknown): part is string =>
|
const valid = (name: unknown): name is string =>
|
||||||
typeof part === "string" && part === `${name}${safeExtension(part)}`;
|
typeof name === "string" && name === `${fileID}${safeExtension(name)}`;
|
||||||
try {
|
try {
|
||||||
const { image, video } = JSON.parse(
|
const { image, video } = JSON.parse(
|
||||||
readFileSync(join(dir, livePhotoJSONName(name)), "utf-8"),
|
readFileSync(join(dir, livePhotoJSONName(fileID)), "utf-8"),
|
||||||
);
|
);
|
||||||
if (valid(image) && valid(video)) {
|
if (valid(image) && valid(video)) {
|
||||||
return { path: join(dir, image), videoPath: join(dir, video) };
|
return { path: join(dir, image), videoPath: join(dir, video) };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No such file, or not one quak wrote.
|
// No such file, or not one the cache wrote.
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
@@ -306,11 +303,11 @@ const readLivePhotoJSON = (
|
|||||||
// Write the JSON file naming a live photo's image and video, both in `dir`.
|
// Write the JSON file naming a live photo's image and video, both in `dir`.
|
||||||
export const writeLivePhotoJSON = (
|
export const writeLivePhotoJSON = (
|
||||||
dir: string,
|
dir: string,
|
||||||
name: string,
|
fileID: number,
|
||||||
stored: { path: string; videoPath: string },
|
stored: { path: string; videoPath: string },
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
writeAtomic(
|
writeAtomic(
|
||||||
join(dir, livePhotoJSONName(name)),
|
join(dir, livePhotoJSONName(fileID)),
|
||||||
new TextEncoder().encode(
|
new TextEncoder().encode(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
image: basename(stored.path),
|
image: basename(stored.path),
|
||||||
@@ -319,19 +316,17 @@ export const writeLivePhotoJSON = (
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The original of `file` as stored in `dir` under `name` (without its
|
// The original of `file` as the cache or a backup stored it in `dir`, when all
|
||||||
// extension), when all of it is there: `<name><ext>`, or a live photo's image
|
// of it is there: `<fileID><ext>`, or a live photo's image and video.
|
||||||
// and video.
|
|
||||||
export const storedOriginal = (
|
export const storedOriginal = (
|
||||||
dir: string,
|
dir: string,
|
||||||
name: string,
|
|
||||||
file: EnteFile,
|
file: EnteFile,
|
||||||
): { path: string; videoPath?: string } | undefined => {
|
): { path: string; videoPath?: string } | undefined => {
|
||||||
if (file.metadata.fileType !== "livePhoto") {
|
if (file.metadata.fileType !== "livePhoto") {
|
||||||
const path = join(dir, `${name}${safeExtension(file.metadata.title)}`);
|
const path = join(dir, nameInOriginals(file));
|
||||||
return hasContent(path) ? { path } : undefined;
|
return hasContent(path) ? { path } : undefined;
|
||||||
}
|
}
|
||||||
const stored = readLivePhotoJSON(dir, name);
|
const stored = readLivePhotoJSON(dir, file.id);
|
||||||
return stored !== undefined &&
|
return stored !== undefined &&
|
||||||
hasContent(stored.path) &&
|
hasContent(stored.path) &&
|
||||||
hasContent(stored.videoPath)
|
hasContent(stored.videoPath)
|
||||||
@@ -339,76 +334,10 @@ export const storedOriginal = (
|
|||||||
: undefined;
|
: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The original of `file` as stored at its save path under `root`, when all of
|
|
||||||
// it is there.
|
|
||||||
export const storedAtSavePath = (
|
|
||||||
root: string,
|
|
||||||
file: EnteFile,
|
|
||||||
): { path: string; videoPath?: string } | undefined => {
|
|
||||||
const path = savePath(root, file);
|
|
||||||
return storedOriginal(dirname(path), basename(path, extname(path)), file);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
|
|
||||||
// `dest` appears only once it is whole ("present means complete"). As in the
|
|
||||||
// download writer, the temp file is fsynced before the rename and the directory
|
|
||||||
// after it, so a power cut cannot leave a correctly named but short original.
|
|
||||||
// The temp name carries this process's ID so a later run can tell a leftover
|
|
||||||
// from a copy still in progress (see `removeLeftoverTempFiles`).
|
|
||||||
export const copyAtomic = async (src: string, dest: string): Promise<void> => {
|
|
||||||
if (src === dest) return;
|
|
||||||
const tmp = join(
|
|
||||||
dirname(dest),
|
|
||||||
`.quak-backup-${basename(dest)}-${process.pid}-${Math.random()
|
|
||||||
.toString(36)
|
|
||||||
.slice(2)}.tmp`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await copyFile(src, tmp);
|
|
||||||
await fsyncPath(tmp);
|
|
||||||
// `rename` replaces the destination's directory entry: an existing
|
|
||||||
// symlink at `dest` is replaced, not followed, and the new file has
|
|
||||||
// the temp file's permissions (copied from `src`).
|
|
||||||
await rename(tmp, dest);
|
|
||||||
await fsyncPath(dirname(dest));
|
|
||||||
} finally {
|
|
||||||
await rm(tmp, { force: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Put the original of `file` at its save path under `root`, creating its
|
|
||||||
// folders. `get` is given the save path and returns where the original is: a
|
|
||||||
// fetch writes it there, and a copy the cache holds is copied there. A live
|
|
||||||
// photo's image and video go beside the save path, each with its own
|
|
||||||
// extension: when they came from the cache they are copied. Then the JSON file
|
|
||||||
// naming them is written, which is what makes the live photo count as stored.
|
|
||||||
export const placeOriginal = async (
|
|
||||||
root: string,
|
|
||||||
file: EnteFile,
|
|
||||||
get: (dest: string) => Promise<{ path: string; videoPath?: string }>,
|
|
||||||
): Promise<{ path: string; videoPath?: string }> => {
|
|
||||||
const dest = savePath(root, file);
|
|
||||||
await mkdir(dirname(dest), { recursive: true });
|
|
||||||
const got = await get(dest);
|
|
||||||
if (got.videoPath === undefined) {
|
|
||||||
await copyAtomic(got.path, dest);
|
|
||||||
return { path: dest };
|
|
||||||
}
|
|
||||||
const path = withExtension(dest, extname(got.path));
|
|
||||||
const videoPath = withExtension(dest, extname(got.videoPath));
|
|
||||||
if (got.path !== path) {
|
|
||||||
await copyAtomic(got.path, path);
|
|
||||||
await copyAtomic(got.videoPath, videoPath);
|
|
||||||
}
|
|
||||||
const name = basename(dest, extname(dest));
|
|
||||||
await writeLivePhotoJSON(dirname(dest), name, { path, videoPath });
|
|
||||||
return { path, videoPath };
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
||||||
private readonly pools: RequestPools;
|
private readonly pools: RequestPools;
|
||||||
private readonly source: ContentSource;
|
private readonly source: ContentSource;
|
||||||
private readonly downloadDirectory: string;
|
private readonly downloadDirectory?: string;
|
||||||
private readonly getFile: (fileID: number) => EnteFile | undefined;
|
private readonly getFile: (fileID: number) => EnteFile | undefined;
|
||||||
private readonly originalsDir: string;
|
private readonly originalsDir: string;
|
||||||
private readonly thumbnailsDir: string;
|
private readonly thumbnailsDir: string;
|
||||||
@@ -506,23 +435,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
|
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put the original at the save path of `file` under the download directory
|
// Get an original for a backup. One not present anywhere is written
|
||||||
// and return it there. `file` is the copy the `Photo` holds, so the path is
|
|
||||||
// the one its `savePath` names, even after a refresh changed the date. One
|
|
||||||
// already stored there is returned as it is; one the cache holds is copied
|
|
||||||
// from it; any other is fetched straight to the save path, with no copy
|
|
||||||
// left in the cache.
|
|
||||||
async download(file: EnteFile): Promise<ContentResult> {
|
|
||||||
const root = this.downloadDirectory;
|
|
||||||
const saved =
|
|
||||||
storedAtSavePath(root, file) ??
|
|
||||||
(await placeOriginal(root, file, (dest) =>
|
|
||||||
this.backupOriginal(file.id, dest),
|
|
||||||
));
|
|
||||||
return { ...saved, bytes: fileSize(saved.path) ?? 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get an original for a save path. One not present anywhere is written
|
|
||||||
// straight to `destination` and recorded there, so no second copy lands
|
// straight to `destination` and recorded there, so no second copy lands
|
||||||
// in the cache; one already present is returned where it is.
|
// in the cache; one already present is returned where it is.
|
||||||
async backupOriginal(
|
async backupOriginal(
|
||||||
@@ -687,16 +600,17 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
await this.touch(cached.path);
|
await this.touch(cached.path);
|
||||||
return { ...cached, bytes: size, cached: true };
|
return { ...cached, bytes: size, cached: true };
|
||||||
}
|
}
|
||||||
// A recorded file that has since gone re-fetches below. So does a
|
// A recorded file that has since gone, or a live photo an earlier
|
||||||
// live photo recorded with no video: the cache opened before the
|
// version stored as one ZIP, re-fetches below.
|
||||||
// library's records said it is a live photo, while its image and
|
|
||||||
// video had no JSON file beside them yet.
|
|
||||||
known.delete(fileID);
|
known.delete(fileID);
|
||||||
}
|
}
|
||||||
|
|
||||||
// An original already stored at its save path counts as present.
|
// An original a backup already stored counts as present.
|
||||||
if (kind === "original") {
|
if (kind === "original" && this.downloadDirectory !== undefined) {
|
||||||
const stored = storedAtSavePath(this.downloadDirectory, file);
|
const stored = storedOriginal(
|
||||||
|
join(this.downloadDirectory, "originals"),
|
||||||
|
file,
|
||||||
|
);
|
||||||
if (stored !== undefined) {
|
if (stored !== undefined) {
|
||||||
this.originals.set(fileID, stored);
|
this.originals.set(fileID, stored);
|
||||||
return {
|
return {
|
||||||
@@ -732,7 +646,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
? this.beginOriginalWrite(fileID)
|
? this.beginOriginalWrite(fileID)
|
||||||
: null;
|
: null;
|
||||||
try {
|
try {
|
||||||
const stored = await this.fetchInto(
|
const stored = await this.download(
|
||||||
file,
|
file,
|
||||||
dest,
|
dest,
|
||||||
kind,
|
kind,
|
||||||
@@ -747,12 +661,12 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// `placeOriginal` records a live photo it saves.
|
// A backup records its own live photos.
|
||||||
if (
|
if (
|
||||||
stored.videoPath !== undefined &&
|
stored.videoPath !== undefined &&
|
||||||
opts?.destination === undefined
|
opts?.destination === undefined
|
||||||
) {
|
) {
|
||||||
await writeLivePhotoJSON(dir, String(fileID), {
|
await writeLivePhotoJSON(dir, fileID, {
|
||||||
path: stored.path,
|
path: stored.path,
|
||||||
videoPath: stored.videoPath,
|
videoPath: stored.videoPath,
|
||||||
});
|
});
|
||||||
@@ -775,7 +689,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
|
|
||||||
// Fetch into `destination`, returning where the bytes landed: there, or
|
// Fetch into `destination`, returning where the bytes landed: there, or
|
||||||
// for a live photo, its image and video beside it.
|
// for a live photo, its image and video beside it.
|
||||||
private async fetchInto(
|
private async download(
|
||||||
file: EnteFile,
|
file: EnteFile,
|
||||||
destination: string,
|
destination: string,
|
||||||
kind: Kind,
|
kind: Kind,
|
||||||
@@ -800,10 +714,10 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
await utimes(path, now, now).catch(() => undefined);
|
await utimes(path, now, now).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every stored original that lives under `originalsDir` (a save-path hit
|
// Every stored original that lives under `originalsDir` (a backup-directory
|
||||||
// recorded in the map is excluded), with its size and mtime; a live
|
// hit recorded in the map is excluded), with its size and mtime; a live
|
||||||
// photo's size includes its video. Entries whose file has vanished are
|
// photo's size includes its video. Entries whose file has vanished are
|
||||||
// dropped from the map. Save paths and thumbnails are never counted.
|
// dropped from the map. Backups and thumbnails are never counted.
|
||||||
private async measureOriginals(): Promise<{
|
private async measureOriginals(): Promise<{
|
||||||
entries: {
|
entries: {
|
||||||
fileID: number;
|
fileID: number;
|
||||||
@@ -909,7 +823,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
await rm(
|
await rm(
|
||||||
join(
|
join(
|
||||||
this.originalsDir,
|
this.originalsDir,
|
||||||
livePhotoJSONName(String(e.fileID)),
|
livePhotoJSONName(e.fileID),
|
||||||
),
|
),
|
||||||
{ force: true },
|
{ force: true },
|
||||||
);
|
);
|
||||||
@@ -959,14 +873,20 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
if (id === undefined || !existsSync(path)) continue;
|
if (id === undefined || !existsSync(path)) continue;
|
||||||
// A live photo's image and video are one entry, as the JSON file
|
// A live photo's image and video are one entry, as the JSON file
|
||||||
// beside them names them. A live photo's file with no such JSON
|
// beside them names them. A live photo's file with no such JSON
|
||||||
// file is not its original and is left alone: another process may
|
// file is not its original. If it is a ZIP, it is the one an
|
||||||
// have just stored it and not yet written the JSON file.
|
// earlier version stored under the image's name, and is removed.
|
||||||
const livePhoto = names.has(livePhotoJSONName(String(id)))
|
// Any other is left alone: another process may have just stored
|
||||||
? readLivePhotoJSON(dir, String(id))
|
// it and not yet written the JSON file.
|
||||||
|
const livePhoto = names.has(livePhotoJSONName(id))
|
||||||
|
? readLivePhotoJSON(dir, id)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (livePhoto !== undefined) {
|
if (livePhoto !== undefined) {
|
||||||
into.set(id, livePhoto);
|
into.set(id, livePhoto);
|
||||||
} else if (!isLivePhoto(id)) {
|
} else if (isLivePhoto(id)) {
|
||||||
|
if (isZip(path)) {
|
||||||
|
await rm(path, { force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
into.set(id, { path });
|
into.set(id, { path });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-41
@@ -27,7 +27,7 @@
|
|||||||
// never masked by a subsequent empty refresh.
|
// never masked by a subsequent empty refresh.
|
||||||
|
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join, resolve } from "node:path";
|
import { join } from "node:path";
|
||||||
import envPaths from "env-paths";
|
import envPaths from "env-paths";
|
||||||
|
|
||||||
import { MetadataStore } from "./store.js";
|
import { MetadataStore } from "./store.js";
|
||||||
@@ -49,12 +49,9 @@ import {
|
|||||||
type PhotosAPI,
|
type PhotosAPI,
|
||||||
type TimelineAPI,
|
type TimelineAPI,
|
||||||
type FreshReads,
|
type FreshReads,
|
||||||
type SavePathLookup,
|
|
||||||
} from "./read.js";
|
} from "./read.js";
|
||||||
import {
|
import {
|
||||||
ContentCache,
|
ContentCache,
|
||||||
savePath,
|
|
||||||
storedAtSavePath,
|
|
||||||
type ContentSource,
|
type ContentSource,
|
||||||
type ThumbnailsAPI,
|
type ThumbnailsAPI,
|
||||||
type EnsureOptions,
|
type EnsureOptions,
|
||||||
@@ -73,7 +70,6 @@ export {
|
|||||||
type PhotoFilter,
|
type PhotoFilter,
|
||||||
type TimelineGroup,
|
type TimelineGroup,
|
||||||
type GroupBy,
|
type GroupBy,
|
||||||
type SavePathLookup,
|
|
||||||
} from "./read.js";
|
} from "./read.js";
|
||||||
export {
|
export {
|
||||||
type ContentSource,
|
type ContentSource,
|
||||||
@@ -173,10 +169,8 @@ export interface LibraryOptions {
|
|||||||
// Where `metadata.json` lives. Defaults to the env-paths cache directory
|
// Where `metadata.json` lives. Defaults to the env-paths cache directory
|
||||||
// plus the user id, so each account has its own cache.
|
// plus the user id, so each account has its own cache.
|
||||||
cacheDirectory?: string;
|
cacheDirectory?: string;
|
||||||
// The root of every photo's save path, where `Photo.download()` and
|
// Persistent backup destination. The refresh loop does not use it; the
|
||||||
// `lib.backup()` put originals. Defaults to `photos` in the working
|
// content cache treats an original already stored there as present.
|
||||||
// directory at open. The content cache treats an original already stored
|
|
||||||
// at its save path as present.
|
|
||||||
downloadDirectory?: string;
|
downloadDirectory?: string;
|
||||||
refreshIntervalSeconds?: number;
|
refreshIntervalSeconds?: number;
|
||||||
onProgress?: RefreshProgressCallback;
|
onProgress?: RefreshProgressCallback;
|
||||||
@@ -240,7 +234,7 @@ export interface LibraryStatus {
|
|||||||
|
|
||||||
export class Library {
|
export class Library {
|
||||||
readonly cacheDirectory: string;
|
readonly cacheDirectory: string;
|
||||||
readonly downloadDirectory: string;
|
readonly downloadDirectory?: string;
|
||||||
|
|
||||||
// The in-process read surface (issue #44). Each namespace answers
|
// The in-process read surface (issue #44). Each namespace answers
|
||||||
// synchronously from the live record projection; no read touches the
|
// synchronously from the live record projection; no read touches the
|
||||||
@@ -302,7 +296,7 @@ export class Library {
|
|||||||
store: MetadataStore;
|
store: MetadataStore;
|
||||||
userID: number;
|
userID: number;
|
||||||
cacheDirectory: string;
|
cacheDirectory: string;
|
||||||
downloadDirectory: string;
|
downloadDirectory?: string;
|
||||||
intervalMs: number;
|
intervalMs: number;
|
||||||
onProgress?: RefreshProgressCallback;
|
onProgress?: RefreshProgressCallback;
|
||||||
pools: RequestPools;
|
pools: RequestPools;
|
||||||
@@ -326,14 +320,8 @@ export class Library {
|
|||||||
// The read namespaces derive fresh from the store on each call, so they
|
// The read namespaces derive fresh from the store on each call, so they
|
||||||
// always reflect the latest refresh.
|
// always reflect the latest refresh.
|
||||||
const derive = (): DerivedRecords => this.deriveNow();
|
const derive = (): DerivedRecords => this.deriveNow();
|
||||||
const root = this.downloadDirectory;
|
this.albums = makeAlbumsAPI(derive, this.cache);
|
||||||
const saves: SavePathLookup = {
|
this.photos = makePhotosAPI(derive, this.cache);
|
||||||
savePath: (file) =>
|
|
||||||
storedAtSavePath(root, file)?.path ?? savePath(root, file),
|
|
||||||
isLocal: (file) => storedAtSavePath(root, file) !== undefined,
|
|
||||||
};
|
|
||||||
this.albums = makeAlbumsAPI(derive, saves, this.cache);
|
|
||||||
this.photos = makePhotosAPI(derive, saves, this.cache);
|
|
||||||
this.timeline = makeTimelineAPI(derive);
|
this.timeline = makeTimelineAPI(derive);
|
||||||
this.thumbnails = {
|
this.thumbnails = {
|
||||||
ensure: (opts: EnsureOptions): Promise<EnsureResult[]> => {
|
ensure: (opts: EnsureOptions): Promise<EnsureResult[]> => {
|
||||||
@@ -362,13 +350,6 @@ export class Library {
|
|||||||
const { userID } = opts.client.whoami();
|
const { userID } = opts.client.whoami();
|
||||||
const cacheDirectory =
|
const cacheDirectory =
|
||||||
opts.cacheDirectory ?? defaultCacheDirectory(userID);
|
opts.cacheDirectory ?? defaultCacheDirectory(userID);
|
||||||
if (opts.downloadDirectory === "") {
|
|
||||||
throw new Error(
|
|
||||||
"library: downloadDirectory is empty (leave it out to save " +
|
|
||||||
"under photos/ in the working directory)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const downloadDirectory = opts.downloadDirectory ?? resolve("photos");
|
|
||||||
const metadataPath = join(cacheDirectory, "metadata.json");
|
const metadataPath = join(cacheDirectory, "metadata.json");
|
||||||
let store = await MetadataStore.load(metadataPath);
|
let store = await MetadataStore.load(metadataPath);
|
||||||
// A cache directory given explicitly can hold another account's cache.
|
// A cache directory given explicitly can hold another account's cache.
|
||||||
@@ -423,7 +404,7 @@ export class Library {
|
|||||||
pools,
|
pools,
|
||||||
source,
|
source,
|
||||||
cacheDirectory,
|
cacheDirectory,
|
||||||
downloadDirectory,
|
downloadDirectory: opts.downloadDirectory,
|
||||||
getFile: (fileID) => store.getFileByID(fileID),
|
getFile: (fileID) => store.getFileByID(fileID),
|
||||||
cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes,
|
cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes,
|
||||||
freeBelowBytes: opts.freeBelowBytes,
|
freeBelowBytes: opts.freeBelowBytes,
|
||||||
@@ -445,7 +426,7 @@ export class Library {
|
|||||||
store,
|
store,
|
||||||
userID,
|
userID,
|
||||||
cacheDirectory,
|
cacheDirectory,
|
||||||
downloadDirectory,
|
downloadDirectory: opts.downloadDirectory,
|
||||||
intervalMs,
|
intervalMs,
|
||||||
onProgress: opts.onProgress,
|
onProgress: opts.onProgress,
|
||||||
pools,
|
pools,
|
||||||
@@ -489,10 +470,9 @@ export class Library {
|
|||||||
return this.store.getFile(collectionID, fileID);
|
return this.store.getFile(collectionID, fileID);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The membership of a file its record is read from, addressed by file id
|
// Any membership of a file, addressed by file id alone. A file's own
|
||||||
// alone. A file's own metadata (title, creationTime) is identical across
|
// metadata (title, creationTime) is identical across the collections it
|
||||||
// the collections it belongs to, so this serves the point commands that
|
// belongs to, so this serves the point commands that hold only a fileID.
|
||||||
// hold only a fileID.
|
|
||||||
getFileByID(fileID: number): EnteFile | undefined {
|
getFileByID(fileID: number): EnteFile | undefined {
|
||||||
return this.store.getFileByID(fileID);
|
return this.store.getFileByID(fileID);
|
||||||
}
|
}
|
||||||
@@ -563,20 +543,27 @@ export class Library {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back up every in-scope file to `opts.downloadDirectory`, or else the
|
// Back up every in-scope file to `downloadDirectory` in the historical
|
||||||
// library's, each original at its save path, with a durable failure
|
// on-disk layout, with a durable failure ledger (issue #51). Waits for a
|
||||||
// ledger (issue #51). Waits for a completed refresh first, as `fresh()`
|
// completed refresh first, as `fresh()` does, joining one already running,
|
||||||
// does, joining one already running, and rejects before touching any file
|
// and rejects before touching any file when it fails. Then fetches pending
|
||||||
// when it fails. Then puts pending originals at their save paths as
|
// originals (and optional thumbnails) through the content cache and pools,
|
||||||
// `Photo.download()` does (and optional thumbnails) through the content
|
// and rebuilds the derived symlink/JSON views from the model. Throws before
|
||||||
// cache and pools, and rebuilds the derived symlink/JSON views from the
|
// any network work when no download directory is available or no content
|
||||||
// model. Throws before any network work when no content cache backs the
|
// cache backs the originals it must fetch.
|
||||||
// originals it must fetch.
|
|
||||||
backup(opts?: BackupOptions): Promise<BackupResult> {
|
backup(opts?: BackupOptions): Promise<BackupResult> {
|
||||||
const downloadDirectory =
|
const downloadDirectory =
|
||||||
opts?.downloadDirectory ?? this.downloadDirectory;
|
opts?.downloadDirectory ?? this.downloadDirectory;
|
||||||
const includeOriginals = opts?.includeOriginals ?? true;
|
const includeOriginals = opts?.includeOriginals ?? true;
|
||||||
const includeThumbnails = opts?.includeThumbnails ?? false;
|
const includeThumbnails = opts?.includeThumbnails ?? false;
|
||||||
|
if (!downloadDirectory) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
"backup requires a downloadDirectory (pass one to " +
|
||||||
|
"backup() or open the library with one)",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if ((includeOriginals || includeThumbnails) && !this.cache) {
|
if ((includeOriginals || includeThumbnails) && !this.cache) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new Error(
|
new Error(
|
||||||
|
|||||||
+21
-169
@@ -11,32 +11,15 @@
|
|||||||
// access and, for an album, its photos. They are not sent across IPC — the
|
// access and, for an album, its photos. They are not sent across IPC — the
|
||||||
// plain records are the serializable surface, and `record()` returns one.
|
// plain records are the serializable surface, and `record()` returns one.
|
||||||
//
|
//
|
||||||
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
|
// A `Photo` also fetches its own bytes: `original()` and `thumbnail()` go
|
||||||
// `download()`, `content()`, `exif()` and the methods that each return one
|
// through the on-disk content cache (issue #46), the one place in this module
|
||||||
// EXIF field go through the on-disk content cache (issue #46), and are
|
// that is not synchronous and RAM-only. A library opened without a content
|
||||||
// the one place in this module that may touch the network. A library opened
|
// source leaves that cache absent, and those two methods then throw.
|
||||||
// without a content source leaves that cache absent, and those methods then
|
|
||||||
// throw. `savePath` and `isLocal` look only at the disk and need no cache.
|
|
||||||
|
|
||||||
import { readFile } from "node:fs/promises";
|
import type { CollectionType, FileType } from "../model/types.js";
|
||||||
|
|
||||||
import {
|
|
||||||
readAllExifTags,
|
|
||||||
readPhotoExif,
|
|
||||||
type ExifTags,
|
|
||||||
type PhotoExif,
|
|
||||||
} from "../exif.js";
|
|
||||||
import type { CollectionType, EnteFile, FileType } from "../model/types.js";
|
|
||||||
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
||||||
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
||||||
|
|
||||||
// Where a photo's original is saved, and whether all of it is there. The
|
|
||||||
// library answers both from the disk, with or without a content cache.
|
|
||||||
export interface SavePathLookup {
|
|
||||||
savePath(file: EnteFile): string;
|
|
||||||
isLocal(file: EnteFile): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
||||||
// deterministically — the same order the record projection uses.
|
// deterministically — the same order the record projection uses.
|
||||||
const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
|
const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
|
||||||
@@ -47,23 +30,12 @@ const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
|
|||||||
const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
||||||
b.updationTime - a.updationTime || b.collectionID - a.collectionID;
|
b.updationTime - a.updationTime || b.collectionID - a.collectionID;
|
||||||
|
|
||||||
// A method for each `PhotoExif` field, named after it, taking the options
|
|
||||||
// `exif()` takes and giving that field. `Photo` implements it, so the build's
|
|
||||||
// type check fails when `PhotoExif` has a field `Photo` has no method for.
|
|
||||||
type PhotoExifMethods = {
|
|
||||||
[K in keyof PhotoExif]-?: (opts?: ContentOptions) => Promise<PhotoExif[K]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
|
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
|
||||||
// underlying plain record for callers that need the IPC-safe value. `file` is
|
// underlying plain record for callers that need the IPC-safe value.
|
||||||
// the membership the record is read from, so the save path carries the date of
|
export class Photo {
|
||||||
// `takenAt` and stays known after a refresh removes the file from the library.
|
|
||||||
export class Photo implements PhotoExifMethods {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly rec: PhotoRecord,
|
private readonly rec: PhotoRecord,
|
||||||
private readonly file: EnteFile,
|
private readonly content?: PhotoContent,
|
||||||
private readonly saves: SavePathLookup,
|
|
||||||
private readonly cache?: PhotoContent,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get fileID(): number {
|
get fileID(): number {
|
||||||
@@ -78,13 +50,6 @@ export class Photo implements PhotoExifMethods {
|
|||||||
get takenAt(): number {
|
get takenAt(): number {
|
||||||
return this.rec.takenAt;
|
return this.rec.takenAt;
|
||||||
}
|
}
|
||||||
get modifiedAt(): number {
|
|
||||||
return this.rec.modifiedAt;
|
|
||||||
}
|
|
||||||
// The local-time year of `takenAt`.
|
|
||||||
get year(): number {
|
|
||||||
return new Date(this.rec.takenAt).getFullYear();
|
|
||||||
}
|
|
||||||
get fileType(): FileType {
|
get fileType(): FileType {
|
||||||
return this.rec.fileType;
|
return this.rec.fileType;
|
||||||
}
|
}
|
||||||
@@ -103,9 +68,6 @@ export class Photo implements PhotoExifMethods {
|
|||||||
get longitude(): number | undefined {
|
get longitude(): number | undefined {
|
||||||
return this.rec.longitude;
|
return this.rec.longitude;
|
||||||
}
|
}
|
||||||
get hash(): string | undefined {
|
|
||||||
return this.rec.hash;
|
|
||||||
}
|
|
||||||
get isArchived(): boolean {
|
get isArchived(): boolean {
|
||||||
return this.rec.isArchived;
|
return this.rec.isArchived;
|
||||||
}
|
}
|
||||||
@@ -113,132 +75,30 @@ export class Photo implements PhotoExifMethods {
|
|||||||
return this.rec.isHidden;
|
return this.rec.isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where `download()` and `lib.backup()` put the original under the
|
|
||||||
// library's download directory, whether or not it is there yet:
|
|
||||||
// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`. For a live photo
|
|
||||||
// already stored, its image. For a live photo not yet stored, it carries
|
|
||||||
// the title's extension, and the image may be stored under a different
|
|
||||||
// one.
|
|
||||||
get savePath(): string {
|
|
||||||
return this.saves.savePath(this.file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether the whole original is at `savePath`. A copy only in the cache
|
|
||||||
// does not count.
|
|
||||||
get isLocal(): boolean {
|
|
||||||
return this.saves.isLocal(this.file);
|
|
||||||
}
|
|
||||||
|
|
||||||
record(): PhotoRecord {
|
record(): PhotoRecord {
|
||||||
return this.rec;
|
return this.rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and cache the full-resolution original, returning its on-disk path
|
// Fetch and cache the full-resolution original, returning its on-disk path
|
||||||
// and byte length; for a live photo, its image's, and its video's path as
|
// and byte length; for a live photo, its image's, and its video's path as
|
||||||
// `videoPath`. Served from the cache (or the save path) when already
|
// `videoPath`. Served from the cache (or the backup download directory)
|
||||||
// present, otherwise fetched through the content pool.
|
// when already present, otherwise fetched through the content pool.
|
||||||
async original(opts?: ContentOptions): Promise<ContentResult> {
|
async original(opts?: ContentOptions): Promise<ContentResult> {
|
||||||
return this.cacheOrThrow().original(this.rec.fileID, opts);
|
return this.contentOrThrow().original(this.rec.fileID, opts);
|
||||||
}
|
|
||||||
|
|
||||||
// Put the original at `savePath` and return it there, as `original()`
|
|
||||||
// does. When it is already there, nothing is written. When the cache holds
|
|
||||||
// it, it is copied from there; otherwise it is fetched straight to
|
|
||||||
// `savePath`.
|
|
||||||
async download(): Promise<ContentResult> {
|
|
||||||
return this.cacheOrThrow().download(this.file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// As `original`, for the thumbnail, through the thumbnail pool.
|
// As `original`, for the thumbnail, through the thumbnail pool.
|
||||||
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
||||||
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts);
|
return this.contentOrThrow().thumbnail(this.rec.fileID, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The original's bytes, read from where `original()` puts it. For a live
|
private contentOrThrow(): PhotoContent {
|
||||||
// photo, its image's.
|
if (!this.content) {
|
||||||
async content(opts?: ContentOptions): Promise<Uint8Array> {
|
|
||||||
const { path } = await this.original(opts);
|
|
||||||
return readFile(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every EXIF tag of the original, keyed by name (see `ExifTags`), read
|
|
||||||
// from `content()`, so this may download it. EXIF is read from any image
|
|
||||||
// format exifreader reads, JPEG and HEIC/HEIF among them; any other file
|
|
||||||
// gives `{}`, and a video gives it without fetching anything. Like the
|
|
||||||
// other content methods, it throws when there is no content cache, video
|
|
||||||
// or not.
|
|
||||||
async exif(opts?: ContentOptions): Promise<ExifTags> {
|
|
||||||
this.cacheOrThrow();
|
|
||||||
if (this.rec.fileType === "video") return {};
|
|
||||||
return readAllExifTags(await this.content(opts));
|
|
||||||
}
|
|
||||||
|
|
||||||
// One field each, named and typed as in `PhotoExif`, picked from the tags
|
|
||||||
// `exif()` returns, and undefined when the file lacks it. Each call runs
|
|
||||||
// `exif()`, which reads the original again.
|
|
||||||
async make(opts?: ContentOptions): Promise<PhotoExif["make"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).make;
|
|
||||||
}
|
|
||||||
async model(opts?: ContentOptions): Promise<PhotoExif["model"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).model;
|
|
||||||
}
|
|
||||||
async lensModel(opts?: ContentOptions): Promise<PhotoExif["lensModel"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).lensModel;
|
|
||||||
}
|
|
||||||
async dateTimeOriginal(
|
|
||||||
opts?: ContentOptions,
|
|
||||||
): Promise<PhotoExif["dateTimeOriginal"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).dateTimeOriginal;
|
|
||||||
}
|
|
||||||
async offsetTimeOriginal(
|
|
||||||
opts?: ContentOptions,
|
|
||||||
): Promise<PhotoExif["offsetTimeOriginal"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).offsetTimeOriginal;
|
|
||||||
}
|
|
||||||
async exposureTime(
|
|
||||||
opts?: ContentOptions,
|
|
||||||
): Promise<PhotoExif["exposureTime"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).exposureTime;
|
|
||||||
}
|
|
||||||
async fNumber(opts?: ContentOptions): Promise<PhotoExif["fNumber"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).fNumber;
|
|
||||||
}
|
|
||||||
async iso(opts?: ContentOptions): Promise<PhotoExif["iso"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).iso;
|
|
||||||
}
|
|
||||||
async focalLength(
|
|
||||||
opts?: ContentOptions,
|
|
||||||
): Promise<PhotoExif["focalLength"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).focalLength;
|
|
||||||
}
|
|
||||||
async orientation(
|
|
||||||
opts?: ContentOptions,
|
|
||||||
): Promise<PhotoExif["orientation"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).orientation;
|
|
||||||
}
|
|
||||||
async gpsLatitude(
|
|
||||||
opts?: ContentOptions,
|
|
||||||
): Promise<PhotoExif["gpsLatitude"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).gpsLatitude;
|
|
||||||
}
|
|
||||||
async gpsLongitude(
|
|
||||||
opts?: ContentOptions,
|
|
||||||
): Promise<PhotoExif["gpsLongitude"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).gpsLongitude;
|
|
||||||
}
|
|
||||||
async gpsAltitude(
|
|
||||||
opts?: ContentOptions,
|
|
||||||
): Promise<PhotoExif["gpsAltitude"]> {
|
|
||||||
return readPhotoExif(await this.exif(opts)).gpsAltitude;
|
|
||||||
}
|
|
||||||
|
|
||||||
private cacheOrThrow(): PhotoContent {
|
|
||||||
if (!this.cache) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Photo content requires a library opened with a content cache",
|
"Photo content requires a library opened with a content cache",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.cache;
|
return this.content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +108,6 @@ export class Album {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly rec: AlbumRecord,
|
private readonly rec: AlbumRecord,
|
||||||
private readonly records: DerivedRecords,
|
private readonly records: DerivedRecords,
|
||||||
private readonly saves: SavePathLookup,
|
|
||||||
private readonly content?: PhotoContent,
|
private readonly content?: PhotoContent,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -283,10 +142,7 @@ export class Album {
|
|||||||
const out: Photo[] = [];
|
const out: Photo[] = [];
|
||||||
for (const id of this.rec.fileIDs) {
|
for (const id of this.rec.fileIDs) {
|
||||||
const p = this.records.photos.get(id);
|
const p = this.records.photos.get(id);
|
||||||
const file = this.records.files.get(id);
|
if (p) out.push(new Photo(p, this.content));
|
||||||
if (p && file) {
|
|
||||||
out.push(new Photo(p, file, this.saves, this.content));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -349,19 +205,18 @@ export interface FreshReads {
|
|||||||
|
|
||||||
export const makeAlbumsAPI = (
|
export const makeAlbumsAPI = (
|
||||||
derive: () => DerivedRecords,
|
derive: () => DerivedRecords,
|
||||||
saves: SavePathLookup,
|
|
||||||
content?: PhotoContent,
|
content?: PhotoContent,
|
||||||
): AlbumsAPI => ({
|
): AlbumsAPI => ({
|
||||||
list: (): Album[] => {
|
list: (): Album[] => {
|
||||||
const records = derive();
|
const records = derive();
|
||||||
return [...records.albums.values()]
|
return [...records.albums.values()]
|
||||||
.sort(byNewestAlbum)
|
.sort(byNewestAlbum)
|
||||||
.map((rec) => new Album(rec, records, saves, content));
|
.map((rec) => new Album(rec, records, content));
|
||||||
},
|
},
|
||||||
byID: ({ collectionID }): Album | undefined => {
|
byID: ({ collectionID }): Album | undefined => {
|
||||||
const records = derive();
|
const records = derive();
|
||||||
const rec = records.albums.get(collectionID);
|
const rec = records.albums.get(collectionID);
|
||||||
return rec ? new Album(rec, records, saves, content) : undefined;
|
return rec ? new Album(rec, records, content) : undefined;
|
||||||
},
|
},
|
||||||
byName: ({ albumName }): Album | undefined => {
|
byName: ({ albumName }): Album | undefined => {
|
||||||
const records = derive();
|
const records = derive();
|
||||||
@@ -370,20 +225,17 @@ export const makeAlbumsAPI = (
|
|||||||
const match = [...records.albums.values()]
|
const match = [...records.albums.values()]
|
||||||
.sort(byNewestAlbum)
|
.sort(byNewestAlbum)
|
||||||
.find((rec) => rec.name === albumName);
|
.find((rec) => rec.name === albumName);
|
||||||
return match ? new Album(match, records, saves, content) : undefined;
|
return match ? new Album(match, records, content) : undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const makePhotosAPI = (
|
export const makePhotosAPI = (
|
||||||
derive: () => DerivedRecords,
|
derive: () => DerivedRecords,
|
||||||
saves: SavePathLookup,
|
|
||||||
content?: PhotoContent,
|
content?: PhotoContent,
|
||||||
): PhotosAPI => ({
|
): PhotosAPI => ({
|
||||||
byID: ({ fileID }): Photo | undefined => {
|
byID: ({ fileID }): Photo | undefined => {
|
||||||
const records = derive();
|
const rec = derive().photos.get(fileID);
|
||||||
const rec = records.photos.get(fileID);
|
return rec ? new Photo(rec, content) : undefined;
|
||||||
const file = records.files.get(fileID);
|
|
||||||
return rec && file ? new Photo(rec, file, saves, content) : undefined;
|
|
||||||
},
|
},
|
||||||
records: ({ fileIDs }): PhotoRecord[] => {
|
records: ({ fileIDs }): PhotoRecord[] => {
|
||||||
const { photos } = derive();
|
const { photos } = derive();
|
||||||
|
|||||||
+17
-43
@@ -5,11 +5,10 @@
|
|||||||
// owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in
|
// owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in
|
||||||
// the main process; the window only ever sees these records.
|
// the main process; the window only ever sees these records.
|
||||||
//
|
//
|
||||||
// Ente holds edited/basic times in microseconds; records expose `takenAt` and
|
// Ente holds edited/basic times in microseconds; records expose `takenAt` in
|
||||||
// `modifiedAt` in milliseconds. The magic-metadata field names below are the
|
// milliseconds. The magic-metadata field names below are the ones the Ente
|
||||||
// ones the Ente clients write, confirmed against the repo's own fixtures:
|
// clients write, confirmed against the repo's own fixtures: `w`/`h` in
|
||||||
// `w`/`h` in test/cli/metadata-backup.test.ts, `visibility` in
|
// test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts.
|
||||||
// test/library/store.test.ts.
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Collection,
|
Collection,
|
||||||
@@ -34,17 +33,12 @@ export interface PhotoRecord {
|
|||||||
// Milliseconds. `pubMagicMetadata.editedTime` when the user edited the
|
// Milliseconds. `pubMagicMetadata.editedTime` when the user edited the
|
||||||
// date, else basic-metadata `creationTime`.
|
// date, else basic-metadata `creationTime`.
|
||||||
takenAt: number;
|
takenAt: number;
|
||||||
// Milliseconds. Basic-metadata `modificationTime`.
|
|
||||||
modifiedAt: number;
|
|
||||||
fileType: FileType;
|
fileType: FileType;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
// The content hash the uploader recorded (`FileMetadata.hash`); files from
|
|
||||||
// very old clients have none.
|
|
||||||
hash?: string;
|
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
isHidden: boolean;
|
isHidden: boolean;
|
||||||
// Local cache paths, set once a later phase caches the bytes; unset here.
|
// Local cache paths, set once a later phase caches the bytes; unset here.
|
||||||
@@ -86,10 +80,6 @@ export interface LibraryChange {
|
|||||||
export interface DerivedRecords {
|
export interface DerivedRecords {
|
||||||
albums: Map<number, AlbumRecord>;
|
albums: Map<number, AlbumRecord>;
|
||||||
photos: Map<number, PhotoRecord>;
|
photos: Map<number, PhotoRecord>;
|
||||||
// The membership each photo's record is read from, for its `Photo`'s save
|
|
||||||
// path. It holds the file's key, so it stays in this process: no snapshot
|
|
||||||
// or change carries it.
|
|
||||||
files: Map<number, EnteFile>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const asString = (v: unknown): string | undefined =>
|
const asString = (v: unknown): string | undefined =>
|
||||||
@@ -105,29 +95,10 @@ const microsToMillis = (micros: number): number => Math.floor(micros / 1000);
|
|||||||
const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number =>
|
const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number =>
|
||||||
b.takenAt - a.takenAt || b.fileID - a.fileID;
|
b.takenAt - a.takenAt || b.fileID - a.fileID;
|
||||||
|
|
||||||
// A photo's `takenAt` in milliseconds: `pubMagicMetadata.editedTime` when the
|
|
||||||
// user edited the date, else basic-metadata `creationTime`.
|
|
||||||
export const takenAtOf = (file: EnteFile): number =>
|
|
||||||
microsToMillis(
|
|
||||||
asNumber(file.pubMagicMetadata?.editedTime) ??
|
|
||||||
file.metadata.creationTime,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The membership a file's record is read from: the most recently synced, lowest
|
|
||||||
// collection id to break ties. Whatever dates a file's save path takes this
|
|
||||||
// membership too, so the path always carries the record's `takenAt`.
|
|
||||||
export const representative = (memberships: EnteFile[]): EnteFile =>
|
|
||||||
memberships.reduce((best, m) =>
|
|
||||||
m.updationTime > best.updationTime ||
|
|
||||||
(m.updationTime === best.updationTime &&
|
|
||||||
m.collectionID < best.collectionID)
|
|
||||||
? m
|
|
||||||
: best,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build one PhotoRecord from every membership of a file. The memberships share
|
// Build one PhotoRecord from every membership of a file. The memberships share
|
||||||
// the same underlying file, so metadata is read from a single representative;
|
// the same underlying file, so metadata is read from a single representative
|
||||||
// `albumIDs` gathers them all.
|
// (the most recently synced, lowest collection id to break ties); `albumIDs`
|
||||||
|
// gathers them all.
|
||||||
const toPhotoRecord = (
|
const toPhotoRecord = (
|
||||||
fileID: number,
|
fileID: number,
|
||||||
memberships: EnteFile[],
|
memberships: EnteFile[],
|
||||||
@@ -135,19 +106,25 @@ const toPhotoRecord = (
|
|||||||
const albumIDs = memberships
|
const albumIDs = memberships
|
||||||
.map((m) => m.collectionID)
|
.map((m) => m.collectionID)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
const rep = representative(memberships);
|
const rep = memberships.reduce((best, m) =>
|
||||||
|
m.updationTime > best.updationTime ||
|
||||||
|
(m.updationTime === best.updationTime &&
|
||||||
|
m.collectionID < best.collectionID)
|
||||||
|
? m
|
||||||
|
: best,
|
||||||
|
);
|
||||||
|
|
||||||
const pub = rep.pubMagicMetadata ?? {};
|
const pub = rep.pubMagicMetadata ?? {};
|
||||||
const priv = rep.magicMetadata ?? {};
|
const priv = rep.magicMetadata ?? {};
|
||||||
|
|
||||||
|
const takenAtMicros = asNumber(pub.editedTime) ?? rep.metadata.creationTime;
|
||||||
const visibility = asNumber(priv.visibility);
|
const visibility = asNumber(priv.visibility);
|
||||||
|
|
||||||
const record: PhotoRecord = {
|
const record: PhotoRecord = {
|
||||||
fileID,
|
fileID,
|
||||||
albumIDs,
|
albumIDs,
|
||||||
title: asString(pub.editedName) ?? rep.metadata.title,
|
title: asString(pub.editedName) ?? rep.metadata.title,
|
||||||
takenAt: takenAtOf(rep),
|
takenAt: microsToMillis(takenAtMicros),
|
||||||
modifiedAt: microsToMillis(rep.metadata.modificationTime),
|
|
||||||
fileType: rep.metadata.fileType,
|
fileType: rep.metadata.fileType,
|
||||||
isArchived: visibility === VISIBILITY_ARCHIVED,
|
isArchived: visibility === VISIBILITY_ARCHIVED,
|
||||||
isHidden: visibility === VISIBILITY_HIDDEN,
|
isHidden: visibility === VISIBILITY_HIDDEN,
|
||||||
@@ -163,7 +140,6 @@ const toPhotoRecord = (
|
|||||||
record.latitude = rep.metadata.latitude;
|
record.latitude = rep.metadata.latitude;
|
||||||
if (rep.metadata.longitude !== undefined)
|
if (rep.metadata.longitude !== undefined)
|
||||||
record.longitude = rep.metadata.longitude;
|
record.longitude = rep.metadata.longitude;
|
||||||
if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash;
|
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
};
|
};
|
||||||
@@ -214,7 +190,6 @@ export const deriveRecords = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const photos = new Map<number, PhotoRecord>();
|
const photos = new Map<number, PhotoRecord>();
|
||||||
const photoFiles = new Map<number, EnteFile>();
|
|
||||||
const takenAtByFile = new Map<number, number>();
|
const takenAtByFile = new Map<number, number>();
|
||||||
for (const [fileID, memberships] of byFileID) {
|
for (const [fileID, memberships] of byFileID) {
|
||||||
const record = toPhotoRecord(fileID, memberships);
|
const record = toPhotoRecord(fileID, memberships);
|
||||||
@@ -226,7 +201,6 @@ export const deriveRecords = (
|
|||||||
record.thumbnailPath = paths.thumbnailPath;
|
record.thumbnailPath = paths.thumbnailPath;
|
||||||
}
|
}
|
||||||
photos.set(fileID, record);
|
photos.set(fileID, record);
|
||||||
photoFiles.set(fileID, representative(memberships));
|
|
||||||
takenAtByFile.set(fileID, record.takenAt);
|
takenAtByFile.set(fileID, record.takenAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +209,7 @@ export const deriveRecords = (
|
|||||||
albums.set(c.id, toAlbumRecord(c, files, takenAtByFile));
|
albums.set(c.id, toAlbumRecord(c, files, takenAtByFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { albums, photos, files: photoFiles };
|
return { albums, photos };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sorted, GUI-ready arrays: albums newest updated first, photos newest first.
|
// Sorted, GUI-ready arrays: albums newest updated first, photos newest first.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { dirname } from "node:path";
|
|||||||
|
|
||||||
import { writeAtomic } from "../download/index.js";
|
import { writeAtomic } from "../download/index.js";
|
||||||
import type { Collection, EnteFile, Microseconds } from "../model/types.js";
|
import type { Collection, EnteFile, Microseconds } from "../model/types.js";
|
||||||
import { representative } from "./records.js";
|
|
||||||
|
|
||||||
// Bumped only when the on-disk shape changes incompatibly. A file written
|
// Bumped only when the on-disk shape changes incompatibly. A file written
|
||||||
// under a different version is discarded on load (see `load`): re-fetching
|
// under a different version is discarded on load (see `load`): re-fetching
|
||||||
@@ -180,16 +179,14 @@ export class MetadataStore {
|
|||||||
return this.files.get(fileKey(collectionID, fileID));
|
return this.files.get(fileKey(collectionID, fileID));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The membership of a file its record is read from (`representative`), or
|
// Any membership of a file, or undefined. Every membership re-wraps the
|
||||||
// undefined. Any membership could fetch the bytes, but the content cache
|
// same underlying content key, so any one is enough to fetch the bytes;
|
||||||
// resolves a fileID to a file this way so that it dates the save path
|
// the content cache resolves a fileID to a file this way.
|
||||||
// from the same membership as `photo.savePath`.
|
|
||||||
getFileByID(fileID: number): EnteFile | undefined {
|
getFileByID(fileID: number): EnteFile | undefined {
|
||||||
const memberships: EnteFile[] = [];
|
|
||||||
for (const file of this.files.values()) {
|
for (const file of this.files.values()) {
|
||||||
if (file.id === fileID) memberships.push(file);
|
if (file.id === fileID) return file;
|
||||||
}
|
}
|
||||||
return memberships.length > 0 ? representative(memberships) : undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
listFiles(collectionID: number): EnteFile[] {
|
listFiles(collectionID: number): EnteFile[] {
|
||||||
|
|||||||
+67
-16
@@ -1,8 +1,8 @@
|
|||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import * as jpeg from "jpeg-js";
|
import * as jpeg from "jpeg-js";
|
||||||
|
import exifReader from "exif-reader";
|
||||||
import type { Client } from "./client.js";
|
import type { Client } from "./client.js";
|
||||||
import { readExifTags } from "./exif.js";
|
|
||||||
import type { Library, Photo } from "./library/index.js";
|
import type { Library, Photo } from "./library/index.js";
|
||||||
import { sanitizeFileName } from "./filename.js";
|
import { sanitizeFileName } from "./filename.js";
|
||||||
import {
|
import {
|
||||||
@@ -19,10 +19,63 @@ export interface MetadataBackupOptions {
|
|||||||
onProgress?: ProgressCallback;
|
onProgress?: ProgressCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract dimensions, EXIF and XMP from a file's bytes. `exif` is the EXIF tags
|
// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
|
||||||
// exifreader returns, from any image format it reads. When it finds an EXIF
|
// data, starting at the "Exif\0\0" header) when there is one, nothing when the
|
||||||
// block but reads no tag from it, the record keeps the block's bytes, base64,
|
// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
|
||||||
// in `exifRaw`, with the reason in `exifError`.
|
// malformed. Each segment length is checked against the bytes that remain and
|
||||||
|
// each step moves forward by at least 4 bytes, so the scan ends on any input.
|
||||||
|
export const extractExifFromJpeg = (
|
||||||
|
buf: Uint8Array,
|
||||||
|
): { exif?: Buffer; error?: string } => {
|
||||||
|
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {};
|
||||||
|
let offset = 2;
|
||||||
|
while (offset < buf.length) {
|
||||||
|
if (offset + 2 > buf.length)
|
||||||
|
return { error: `truncated segment marker at byte ${offset}` };
|
||||||
|
if (buf[offset] !== 0xff)
|
||||||
|
return { error: `no segment marker at byte ${offset}` };
|
||||||
|
const marker = buf[offset + 1]!;
|
||||||
|
if (marker === 0xda) return {}; // start of scan, no more markers
|
||||||
|
if (offset + 4 > buf.length)
|
||||||
|
return { error: `truncated segment length at byte ${offset}` };
|
||||||
|
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
|
||||||
|
// The length counts its own two bytes, so anything under 2 is invalid.
|
||||||
|
if (len < 2)
|
||||||
|
return {
|
||||||
|
error: `segment length ${len} at byte ${offset} is too small`,
|
||||||
|
};
|
||||||
|
if (offset + 2 + len > buf.length)
|
||||||
|
return {
|
||||||
|
error: `segment length ${len} at byte ${offset} runs past the end of the file`,
|
||||||
|
};
|
||||||
|
if (marker === 0xe1) {
|
||||||
|
// APP1 — check for "Exif\0\0" header. A length under 8 cannot hold
|
||||||
|
// the six-byte header, so the segment is not EXIF; below 6 the
|
||||||
|
// bytes compared would also lie past the segment.
|
||||||
|
if (
|
||||||
|
len >= 8 &&
|
||||||
|
buf[offset + 4] === 0x45 &&
|
||||||
|
buf[offset + 5] === 0x78 &&
|
||||||
|
buf[offset + 6] === 0x69 &&
|
||||||
|
buf[offset + 7] === 0x66
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
exif: Buffer.from(
|
||||||
|
buf.buffer,
|
||||||
|
buf.byteOffset + offset + 4,
|
||||||
|
len - 2,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += 2 + len;
|
||||||
|
}
|
||||||
|
return { error: "file ends before the image data" };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment
|
||||||
|
// is malformed or cannot be parsed, the record carries the reason in
|
||||||
|
// `exifError`.
|
||||||
export const extractImageMetadata = (
|
export const extractImageMetadata = (
|
||||||
fileBytes: Uint8Array,
|
fileBytes: Uint8Array,
|
||||||
): Record<string, unknown> | undefined => {
|
): Record<string, unknown> | undefined => {
|
||||||
@@ -39,21 +92,19 @@ export const extractImageMetadata = (
|
|||||||
result.height = decoded.height;
|
result.height = decoded.height;
|
||||||
} catch {
|
} catch {
|
||||||
// Not every original is a JPEG (PNG, HEIC, video), so a failed decode
|
// Not every original is a JPEG (PNG, HEIC, video), so a failed decode
|
||||||
// is expected and only means no dimensions; unreadable EXIF is still
|
// is expected and only means no dimensions; a malformed JPEG is still
|
||||||
// reported below through `exifError`.
|
// reported below through `exifError`.
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = readExifTags(fileBytes);
|
const { exif, error } = extractExifFromJpeg(fileBytes);
|
||||||
if (tags?.exif && Object.keys(tags.exif).length > 0) {
|
if (error) result.exifError = error;
|
||||||
result.exif = tags.exif;
|
if (exif) {
|
||||||
} else if (tags?.exif) {
|
try {
|
||||||
const block = tags.metadataRange?.blocks.find((b) => b.type === "exif");
|
result.exif = exifReader(exif);
|
||||||
if (block) {
|
} catch (err) {
|
||||||
result.exifRaw = Buffer.from(
|
result.exifRaw = exif.toString("base64");
|
||||||
fileBytes.subarray(block.start, block.end),
|
result.exifError = err instanceof Error ? err.message : String(err);
|
||||||
).toString("base64");
|
|
||||||
}
|
}
|
||||||
result.exifError = "no tag could be read from the EXIF block";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
|
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
|
||||||
|
|||||||
+146
-178
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for the `quak backup` logic, now built on the library API (issue #51).
|
* Tests for the `quak backup` logic, now built on the library API (issue #51).
|
||||||
*
|
*
|
||||||
* `lib.backup({ downloadDirectory })` refreshes the library, puts each pending
|
* `lib.backup({ downloadDirectory })` refreshes the library, fetches each
|
||||||
* file's original at its save path through the content cache/pools, and
|
* pending file's original through the content cache/pools, and materialises the
|
||||||
* materialises the on-disk layout:
|
* unchanged on-disk layout:
|
||||||
*
|
*
|
||||||
* <downloadDirectory>/
|
* <downloadDirectory>/
|
||||||
* YYYY/YYYY-MM/YYYY-MM-DD/
|
* originals/
|
||||||
* YYYY-MM-DD.<fileID>.<ext> the decrypted bytes ("present means complete")
|
* <fileID>.<ext> the decrypted bytes ("present means complete")
|
||||||
* YYYY-MM-DD.<fileID>.json per-file metadata sidecar (rebuilt each run)
|
* <fileID>.json per-file metadata sidecar (rebuilt each run)
|
||||||
* collections/
|
* collections/
|
||||||
* <name>/<title> symlink to the original (rebuilt each run)
|
* <name>/<title> symlink into ../originals (rebuilt each run)
|
||||||
* <name>.json per-collection metadata (rebuilt each run)
|
* <name>.json per-collection metadata (rebuilt each run)
|
||||||
* failures.json durable ledger of unresolved failures
|
* failures.json durable ledger of unresolved failures
|
||||||
*
|
*
|
||||||
@@ -94,18 +94,6 @@ const USER_ID = 42;
|
|||||||
// Decrypted-byte length each stub original writes, keyed by fileID.
|
// Decrypted-byte length each stub original writes, keyed by fileID.
|
||||||
const SIZE_BY_ID: Record<number, number> = { 100: 3000, 101: 2000, 200: 1500 };
|
const SIZE_BY_ID: Record<number, number> = { 100: 3000, 101: 2000, 200: 1500 };
|
||||||
|
|
||||||
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
|
|
||||||
// stores times, so the machine's time zone cannot move it to another day. Its
|
|
||||||
// original is saved in the folder `DAY`, named `2026-03-01.<fileID>.<ext>`.
|
|
||||||
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
|
|
||||||
const DAY = join("2026", "2026-03", "2026-03-01");
|
|
||||||
|
|
||||||
// Where the backup in `outDir` saves `name` (`<fileID>.<ext>`), and the target
|
|
||||||
// of an album folder's symlink to it.
|
|
||||||
const saved = (outDir: string, name: string): string =>
|
|
||||||
join(outDir, DAY, `2026-03-01.${name}`);
|
|
||||||
const linkTo = (name: string): string => `../../${DAY}/2026-03-01.${name}`;
|
|
||||||
|
|
||||||
const collection = (id: number, name: string): Collection => ({
|
const collection = (id: number, name: string): Collection => ({
|
||||||
id,
|
id,
|
||||||
ownerID: USER_ID,
|
ownerID: USER_ID,
|
||||||
@@ -124,7 +112,7 @@ const file = (id: number, collectionID: number, title: string): EnteFile => ({
|
|||||||
metadata: {
|
metadata: {
|
||||||
title,
|
title,
|
||||||
fileType: "image",
|
fileType: "image",
|
||||||
creationTime: TAKEN,
|
creationTime: 1,
|
||||||
modificationTime: 1,
|
modificationTime: 1,
|
||||||
},
|
},
|
||||||
file: { decryptionHeader: "aGVhZGVy" },
|
file: { decryptionHeader: "aGVhZGVy" },
|
||||||
@@ -265,11 +253,7 @@ describe("lib.backup", () => {
|
|||||||
it("throws before any network when no downloadDirectory is given", async () => {
|
it("throws before any network when no downloadDirectory is given", async () => {
|
||||||
const source = stubSource();
|
const source = stubSource();
|
||||||
const lib = await openLibrary(source);
|
const lib = await openLibrary(source);
|
||||||
// The library always has a download directory, so only an empty one
|
await expect(lib.backup()).rejects.toThrow(/downloadDirectory/i);
|
||||||
// given to backup() reaches runBackup as none.
|
|
||||||
await expect(lib.backup({ downloadDirectory: "" })).rejects.toThrow(
|
|
||||||
/downloadDirectory/i,
|
|
||||||
);
|
|
||||||
expect(source.originalCalls).toBe(0);
|
expect(source.originalCalls).toBe(0);
|
||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
@@ -287,22 +271,28 @@ describe("lib.backup", () => {
|
|||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
expect(result.errors).toEqual([]);
|
expect(result.errors).toEqual([]);
|
||||||
|
|
||||||
// Originals at YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>.
|
// Originals under originals/<fileID>.<ext>.
|
||||||
expect(readFileSync(saved(outDir, "100.jpg")).length).toBe(3000);
|
expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe(
|
||||||
expect(readFileSync(saved(outDir, "101.jpg")).length).toBe(2000);
|
3000,
|
||||||
expect(readFileSync(saved(outDir, "200.png")).length).toBe(1500);
|
);
|
||||||
|
expect(readFileSync(join(outDir, "originals", "101.jpg")).length).toBe(
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
expect(readFileSync(join(outDir, "originals", "200.png")).length).toBe(
|
||||||
|
1500,
|
||||||
|
);
|
||||||
|
|
||||||
// Per-file metadata sidecar.
|
// Per-file metadata sidecar.
|
||||||
const sidecar = JSON.parse(
|
const sidecar = JSON.parse(
|
||||||
readFileSync(saved(outDir, "100.json"), "utf-8"),
|
readFileSync(join(outDir, "originals", "100.json"), "utf-8"),
|
||||||
);
|
);
|
||||||
expect(sidecar.id).toBe(100);
|
expect(sidecar.id).toBe(100);
|
||||||
expect(sidecar.metadata.title).toBe("beach.jpg");
|
expect(sidecar.metadata.title).toBe("beach.jpg");
|
||||||
|
|
||||||
// Collection dirs contain symlinks into the date folders.
|
// Collection dirs contain symlinks into ../originals.
|
||||||
const beach = join(outDir, "collections", "Vacation", "beach.jpg");
|
const beach = join(outDir, "collections", "Vacation", "beach.jpg");
|
||||||
expect(lstatSync(beach).isSymbolicLink()).toBe(true);
|
expect(lstatSync(beach).isSymbolicLink()).toBe(true);
|
||||||
expect(readlinkSync(beach)).toContain(DAY);
|
expect(readlinkSync(beach)).toContain("originals");
|
||||||
expect(readFileSync(beach).length).toBe(3000);
|
expect(readFileSync(beach).length).toBe(3000);
|
||||||
|
|
||||||
// Per-collection metadata JSON.
|
// Per-collection metadata JSON.
|
||||||
@@ -326,7 +316,7 @@ describe("lib.backup", () => {
|
|||||||
|
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
// The title has no usable extension, so the original is `.bin`.
|
// The title has no usable extension, so the original is `.bin`.
|
||||||
expect(existsSync(saved(outDir, "300.bin"))).toBe(true);
|
expect(existsSync(join(outDir, "originals", "300.bin"))).toBe(true);
|
||||||
const link = join(
|
const link = join(
|
||||||
outDir,
|
outDir,
|
||||||
"collections",
|
"collections",
|
||||||
@@ -376,9 +366,9 @@ describe("lib.backup", () => {
|
|||||||
expect(result.errors[0]!.title).toBe("sunset.jpg");
|
expect(result.errors[0]!.title).toBe("sunset.jpg");
|
||||||
|
|
||||||
// The two good files are on disk; the failed one is not.
|
// The two good files are on disk; the failed one is not.
|
||||||
expect(existsSync(saved(outDir, "100.jpg"))).toBe(true);
|
expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true);
|
||||||
expect(existsSync(saved(outDir, "200.png"))).toBe(true);
|
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
|
||||||
expect(existsSync(saved(outDir, "101.jpg"))).toBe(false);
|
expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")),
|
existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -413,7 +403,7 @@ describe("lib.backup", () => {
|
|||||||
const r3 = await lib.backup({ downloadDirectory: outDir });
|
const r3 = await lib.backup({ downloadDirectory: outDir });
|
||||||
expect(r3.failed).toBe(0);
|
expect(r3.failed).toBe(0);
|
||||||
expect(r3.skipped).toBe(2);
|
expect(r3.skipped).toBe(2);
|
||||||
expect(existsSync(saved(outDir, "101.jpg"))).toBe(true);
|
expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(true);
|
||||||
// A ledger with no remaining failures is removed.
|
// A ledger with no remaining failures is removed.
|
||||||
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
||||||
lib.close();
|
lib.close();
|
||||||
@@ -433,8 +423,8 @@ describe("lib.backup", () => {
|
|||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
// Every original still downloads despite the symlink failure.
|
// Every original still downloads despite the symlink failure.
|
||||||
expect(existsSync(saved(outDir, "100.jpg"))).toBe(true);
|
expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true);
|
||||||
expect(existsSync(saved(outDir, "200.png"))).toBe(true);
|
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
|
||||||
// The other symlinks are still built.
|
// The other symlinks are still built.
|
||||||
expect(
|
expect(
|
||||||
lstatSync(
|
lstatSync(
|
||||||
@@ -464,7 +454,7 @@ describe("lib.backup", () => {
|
|||||||
await lib.backup({ downloadDirectory: outDir });
|
await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
// Corrupt a sidecar and delete a symlink between runs.
|
// Corrupt a sidecar and delete a symlink between runs.
|
||||||
writeFileSync(saved(outDir, "100.json"), "not json");
|
writeFileSync(join(outDir, "originals", "100.json"), "not json");
|
||||||
rmSync(join(outDir, "collections", "Vacation", "beach.jpg"));
|
rmSync(join(outDir, "collections", "Vacation", "beach.jpg"));
|
||||||
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
@@ -472,7 +462,7 @@ describe("lib.backup", () => {
|
|||||||
|
|
||||||
// The derived views are repaired from the model.
|
// The derived views are repaired from the model.
|
||||||
const sidecar = JSON.parse(
|
const sidecar = JSON.parse(
|
||||||
readFileSync(saved(outDir, "100.json"), "utf-8"),
|
readFileSync(join(outDir, "originals", "100.json"), "utf-8"),
|
||||||
);
|
);
|
||||||
expect(sidecar.metadata.title).toBe("beach.jpg");
|
expect(sidecar.metadata.title).toBe("beach.jpg");
|
||||||
expect(
|
expect(
|
||||||
@@ -495,8 +485,8 @@ describe("lib.backup", () => {
|
|||||||
|
|
||||||
expect(result.totalFiles).toBe(1);
|
expect(result.totalFiles).toBe(1);
|
||||||
expect(result.downloaded).toBe(1);
|
expect(result.downloaded).toBe(1);
|
||||||
expect(existsSync(saved(outDir, "200.png"))).toBe(true);
|
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
|
||||||
expect(existsSync(saved(outDir, "100.jpg"))).toBe(false);
|
expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(false);
|
||||||
expect(existsSync(join(outDir, "collections", "Work.json"))).toBe(true);
|
expect(existsSync(join(outDir, "collections", "Work.json"))).toBe(true);
|
||||||
expect(existsSync(join(outDir, "collections", "Vacation.json"))).toBe(
|
expect(existsSync(join(outDir, "collections", "Vacation.json"))).toBe(
|
||||||
false,
|
false,
|
||||||
@@ -540,7 +530,7 @@ describe("lib.backup", () => {
|
|||||||
|
|
||||||
expect(result.totalFiles).toBe(1);
|
expect(result.totalFiles).toBe(1);
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
expect(existsSync(saved(outDir, "200.png"))).toBe(true);
|
expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
|
||||||
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
@@ -582,7 +572,7 @@ describe("lib.backup", () => {
|
|||||||
it("fetches each original once and writes it only into the backup", async () => {
|
it("fetches each original once and writes it only into the backup", async () => {
|
||||||
// A backup of a 500 GB account must write 500 GB, not a copy in the
|
// A backup of a 500 GB account must write 500 GB, not a copy in the
|
||||||
// cache as well: an original fetched for the backup goes straight
|
// cache as well: an original fetched for the backup goes straight
|
||||||
// to its save path, and the cache records it there.
|
// into its originals/, and the cache records it there.
|
||||||
const source = stubSource();
|
const source = stubSource();
|
||||||
const lib = await openLibrary(source);
|
const lib = await openLibrary(source);
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
@@ -592,78 +582,23 @@ describe("lib.backup", () => {
|
|||||||
expect(result.downloaded).toBe(3);
|
expect(result.downloaded).toBe(3);
|
||||||
expect(source.originalCalls).toBe(3);
|
expect(source.originalCalls).toBe(3);
|
||||||
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
|
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
|
||||||
const stored = readdirSync(join(outDir, DAY)).filter(
|
const stored = readdirSync(join(outDir, "originals")).filter(
|
||||||
(name) => !name.endsWith(".json"),
|
(name) => !name.endsWith(".json"),
|
||||||
);
|
);
|
||||||
expect(stored.sort()).toEqual([
|
expect(stored.sort()).toEqual(["100.jpg", "101.jpg", "200.png"]);
|
||||||
"2026-03-01.100.jpg",
|
|
||||||
"2026-03-01.101.jpg",
|
|
||||||
"2026-03-01.200.png",
|
|
||||||
]);
|
|
||||||
// The cache counts the backup's copy as present: reading the
|
// The cache counts the backup's copy as present: reading the
|
||||||
// original afterwards fetches nothing and answers with that copy.
|
// original afterwards fetches nothing and answers with that copy.
|
||||||
const read = await lib.photos.byID({ fileID: 100 })!.original();
|
const read = await lib.photos.byID({ fileID: 100 })!.original();
|
||||||
expect(read.path).toBe(saved(outDir, "100.jpg"));
|
expect(read.path).toBe(join(outDir, "originals", "100.jpg"));
|
||||||
expect(source.originalCalls).toBe(3);
|
expect(source.originalCalls).toBe(3);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves a file in two albums at its photo's save path and links it from both", async () => {
|
|
||||||
// The date edited in Ente has reached Work's copy, synced later, but
|
|
||||||
// Vacation's copy still has an earlier edit.
|
|
||||||
const older = file(100, 1, "beach.jpg");
|
|
||||||
older.pubMagicMetadata = {
|
|
||||||
editedTime: new Date(2026, 1, 1, 12).getTime() * 1000,
|
|
||||||
};
|
|
||||||
const newer = file(100, 2, "beach.jpg");
|
|
||||||
newer.updationTime = 2;
|
|
||||||
newer.pubMagicMetadata = {
|
|
||||||
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
|
|
||||||
};
|
|
||||||
class SharedFileClient extends MockClient {
|
|
||||||
override async filesSince(args: {
|
|
||||||
collectionID: number;
|
|
||||||
}): Promise<FilesPage> {
|
|
||||||
const files = [args.collectionID === 1 ? older : newer];
|
|
||||||
return { files, deleted: [], cursor: 1 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const outDir = join(root, "backup");
|
|
||||||
const lib = await Library.open({
|
|
||||||
client: new SharedFileClient(),
|
|
||||||
cacheDirectory: join(root, "cache"),
|
|
||||||
downloadDirectory: outDir,
|
|
||||||
contentSource: stubSource(),
|
|
||||||
refreshIntervalSeconds: 3600,
|
|
||||||
precacheThumbnails: false,
|
|
||||||
precacheOriginals: false,
|
|
||||||
});
|
|
||||||
const photo = lib.photos.byID({ fileID: 100 })!;
|
|
||||||
|
|
||||||
const result = await lib.backup();
|
|
||||||
|
|
||||||
expect(result.totalFiles).toBe(1);
|
|
||||||
expect(result.downloaded).toBe(1);
|
|
||||||
expect(result.failed).toBe(0);
|
|
||||||
expect(photo.savePath).toBe(
|
|
||||||
join(outDir, "2026", "2026-04", "2026-04-15", "2026-04-15.100.jpg"),
|
|
||||||
);
|
|
||||||
expect(photo.isLocal).toBe(true);
|
|
||||||
expect(existsSync(join(outDir, "2026", "2026-02"))).toBe(false);
|
|
||||||
const target = "../../2026/2026-04/2026-04-15/2026-04-15.100.jpg";
|
|
||||||
for (const album of ["Vacation", "Work"]) {
|
|
||||||
expect(
|
|
||||||
readlinkSync(join(outDir, "collections", album, "beach.jpg")),
|
|
||||||
).toBe(target);
|
|
||||||
}
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fsyncs an original copied from the cache before the rename and its directory after", async () => {
|
it("fsyncs an original copied from the cache before the rename and its directory after", async () => {
|
||||||
const lib = await openLibrary(stubSource());
|
const lib = await openLibrary(stubSource());
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
const day = join(outDir, DAY);
|
const originals = join(outDir, "originals");
|
||||||
const dest = saved(outDir, "100.jpg");
|
const dest = join(originals, "100.jpg");
|
||||||
// Only an original already in the cache is copied into the backup;
|
// Only an original already in the cache is copied into the backup;
|
||||||
// one fetched for the backup is written there by the download writer.
|
// one fetched for the backup is written there by the download writer.
|
||||||
await lib.photos.byID({ fileID: 100 })!.original();
|
await lib.photos.byID({ fileID: 100 })!.original();
|
||||||
@@ -674,50 +609,34 @@ describe("lib.backup", () => {
|
|||||||
const at = fsEvents.indexOf(`rename:${dest}`);
|
const at = fsEvents.indexOf(`rename:${dest}`);
|
||||||
expect(at).toBeGreaterThan(0);
|
expect(at).toBeGreaterThan(0);
|
||||||
expect(fsEvents[at - 1]).toMatch(
|
expect(fsEvents[at - 1]).toMatch(
|
||||||
/^sync:.*\/\.quak-backup-2026-03-01\.100\.jpg-\d+-[0-9a-z]*\.tmp$/,
|
/^sync:.*\/\.quak-backup-100\.jpg-\d+-[0-9a-z]*\.tmp$/,
|
||||||
);
|
);
|
||||||
expect(fsEvents[at + 1]).toBe(`sync:${day}`);
|
expect(fsEvents[at + 1]).toBe(`sync:${originals}`);
|
||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes temp files left by a killed backup but not those of one still running", async () => {
|
it("removes temp files left by a killed backup but not those of one still running", async () => {
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
const day = join(outDir, DAY);
|
const originals = join(outDir, "originals");
|
||||||
mkdirSync(day, { recursive: true });
|
mkdirSync(originals, { recursive: true });
|
||||||
// A child that has already exited: its process ID is not running.
|
// A child that has already exited: its process ID is not running.
|
||||||
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
|
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
|
||||||
const leftover = `.quak-backup-2026-03-01.100.jpg-${exitedPID}-abc123.tmp`;
|
const leftover = `.quak-backup-100.jpg-${exitedPID}-abc123.tmp`;
|
||||||
// This test's own process stands in for a backup running at the same
|
// This test's own process stands in for a backup running at the same
|
||||||
// time.
|
// time.
|
||||||
const inProgress = `.quak-backup-2026-03-01.101.jpg-${process.pid}-def456.tmp`;
|
const inProgress = `.quak-backup-101.jpg-${process.pid}-def456.tmp`;
|
||||||
writeFileSync(join(day, leftover), "partial");
|
writeFileSync(join(originals, leftover), "partial");
|
||||||
writeFileSync(join(day, inProgress), "partial");
|
writeFileSync(join(originals, inProgress), "partial");
|
||||||
const lib = await openLibrary(stubSource());
|
const lib = await openLibrary(stubSource());
|
||||||
|
|
||||||
await lib.backup({ downloadDirectory: outDir });
|
await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
const names = readdirSync(day);
|
const names = readdirSync(originals);
|
||||||
expect(names).not.toContain(leftover);
|
expect(names).not.toContain(leftover);
|
||||||
expect(names).toContain(inProgress);
|
expect(names).toContain(inProgress);
|
||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes temp files left in a date folder no file in the backup is saved in", async () => {
|
|
||||||
const outDir = join(root, "backup");
|
|
||||||
// As for a file since deleted, or given another date, after a run was
|
|
||||||
// killed while writing it.
|
|
||||||
const otherDay = join(outDir, "2025", "2025-01", "2025-01-02");
|
|
||||||
mkdirSync(otherDay, { recursive: true });
|
|
||||||
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
|
|
||||||
writeFileSync(join(otherDay, `.quak-${exitedPID}-abc123.tmp`), "x");
|
|
||||||
const lib = await openLibrary(stubSource());
|
|
||||||
|
|
||||||
await lib.backup({ downloadDirectory: outDir });
|
|
||||||
|
|
||||||
expect(readdirSync(otherDay)).toEqual([]);
|
|
||||||
lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes leftover temp files in thumbnails/ but not those of a backup still running", async () => {
|
it("removes leftover temp files in thumbnails/ but not those of a backup still running", async () => {
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
const thumbnails = join(outDir, "thumbnails");
|
const thumbnails = join(outDir, "thumbnails");
|
||||||
@@ -790,7 +709,7 @@ describe("the refresh before a backup", () => {
|
|||||||
const result = await backup;
|
const result = await backup;
|
||||||
|
|
||||||
expect(result.totalFiles).toBe(4);
|
expect(result.totalFiles).toBe(4);
|
||||||
expect(existsSync(saved(outDir, "300.jpg"))).toBe(true);
|
expect(existsSync(join(outDir, "originals", "300.jpg"))).toBe(true);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -809,7 +728,7 @@ describe("the refresh before a backup", () => {
|
|||||||
|
|
||||||
expect(source.originalCalls).toBe(0);
|
expect(source.originalCalls).toBe(0);
|
||||||
expect(readFileSync(ledgerPath, "utf-8")).toBe(ledgerBefore);
|
expect(readFileSync(ledgerPath, "utf-8")).toBe(ledgerBefore);
|
||||||
expect(existsSync(join(outDir, DAY))).toBe(false);
|
expect(existsSync(join(outDir, "originals"))).toBe(false);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -900,13 +819,13 @@ describe("backup album folders", () => {
|
|||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Trip (10)/",
|
"Trip (10)/",
|
||||||
`Trip (10)/IMG_0001 (1).JPG -> ${linkTo("1.JPG")}`,
|
"Trip (10)/IMG_0001 (1).JPG -> ../../originals/1.JPG",
|
||||||
`Trip (10)/IMG_0001 (2).JPG -> ${linkTo("2.JPG")}`,
|
"Trip (10)/IMG_0001 (2).JPG -> ../../originals/2.JPG",
|
||||||
`Trip (10)/img_0001 (4).jpg -> ${linkTo("4.jpg")}`,
|
"Trip (10)/img_0001 (4).jpg -> ../../originals/4.jpg",
|
||||||
`Trip (10)/other.jpg -> ${linkTo("3.jpg")}`,
|
"Trip (10)/other.jpg -> ../../originals/3.jpg",
|
||||||
"Trip (10).json",
|
"Trip (10).json",
|
||||||
"Trip (11)/",
|
"Trip (11)/",
|
||||||
`Trip (11)/other.jpg -> ${linkTo("3.jpg")}`,
|
"Trip (11)/other.jpg -> ../../originals/3.jpg",
|
||||||
"Trip (11).json",
|
"Trip (11).json",
|
||||||
]);
|
]);
|
||||||
expect(albumID(outDir, "Trip (10).json")).toBe(10);
|
expect(albumID(outDir, "Trip (10).json")).toBe(10);
|
||||||
@@ -939,14 +858,14 @@ describe("backup album folders", () => {
|
|||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Trip (10)/",
|
"Trip (10)/",
|
||||||
`Trip (10)/IMG (6) (5).JPG -> ${linkTo("5.JPG")}`,
|
"Trip (10)/IMG (6) (5).JPG -> ../../originals/5.JPG",
|
||||||
`Trip (10)/IMG (6).JPG -> ${linkTo("6.JPG")}`,
|
"Trip (10)/IMG (6).JPG -> ../../originals/6.JPG",
|
||||||
`Trip (10)/IMG (7).JPG -> ${linkTo("7.JPG")}`,
|
"Trip (10)/IMG (7).JPG -> ../../originals/7.JPG",
|
||||||
"Trip (10).json",
|
"Trip (10).json",
|
||||||
"Trip (11)/",
|
"Trip (11)/",
|
||||||
`Trip (11)/a.jpg -> ${linkTo("8.jpg")}`,
|
"Trip (11)/a.jpg -> ../../originals/8.jpg",
|
||||||
"Trip (11) (12)/",
|
"Trip (11) (12)/",
|
||||||
`Trip (11) (12)/b.jpg -> ${linkTo("9.jpg")}`,
|
"Trip (11) (12)/b.jpg -> ../../originals/9.jpg",
|
||||||
"Trip (11) (12).json",
|
"Trip (11) (12).json",
|
||||||
"Trip (11).json",
|
"Trip (11).json",
|
||||||
]);
|
]);
|
||||||
@@ -1012,13 +931,13 @@ describe("backup album folders", () => {
|
|||||||
expect(scoped.failed).toBe(0);
|
expect(scoped.failed).toBe(0);
|
||||||
expect(before).toEqual([
|
expect(before).toEqual([
|
||||||
"Trip (10)/",
|
"Trip (10)/",
|
||||||
`Trip (10)/a.jpg -> ${linkTo("1.jpg")}`,
|
"Trip (10)/a.jpg -> ../../originals/1.jpg",
|
||||||
"Trip (10).json",
|
"Trip (10).json",
|
||||||
"Work/",
|
"Work/",
|
||||||
`Work/c.jpg -> ${linkTo("3.jpg")}`,
|
"Work/c.jpg -> ../../originals/3.jpg",
|
||||||
"Work.json",
|
"Work.json",
|
||||||
"trip (11)/",
|
"trip (11)/",
|
||||||
`trip (11)/b.jpg -> ${linkTo("2.jpg")}`,
|
"trip (11)/b.jpg -> ../../originals/2.jpg",
|
||||||
"trip (11).json",
|
"trip (11).json",
|
||||||
]);
|
]);
|
||||||
expect(tree(outDir)).toEqual(before);
|
expect(tree(outDir)).toEqual(before);
|
||||||
@@ -1071,13 +990,13 @@ describe("backup album folders", () => {
|
|||||||
"Mine/",
|
"Mine/",
|
||||||
"Mine/keep.txt",
|
"Mine/keep.txt",
|
||||||
"Office/",
|
"Office/",
|
||||||
`Office/a.jpg -> ${linkTo("5.jpg")}`,
|
"Office/a.jpg -> ../../originals/5.jpg",
|
||||||
"Office.json",
|
"Office.json",
|
||||||
"Trip/",
|
"Trip/",
|
||||||
`Trip/IMG_0001.JPG -> ${linkTo("1.JPG")}`,
|
"Trip/IMG_0001.JPG -> ../../originals/1.JPG",
|
||||||
"Trip/mine -> ../elsewhere",
|
"Trip/mine -> ../elsewhere",
|
||||||
"Trip/notes.txt",
|
"Trip/notes.txt",
|
||||||
`Trip/other.jpg -> ${linkTo("3.jpg")}`,
|
"Trip/other.jpg -> ../../originals/3.jpg",
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
"Work/",
|
"Work/",
|
||||||
"Work/keep.txt",
|
"Work/keep.txt",
|
||||||
@@ -1086,7 +1005,7 @@ describe("backup album folders", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// One album backed up, then a folder the user made beside it holding a
|
// One album backed up, then a folder the user made beside it holding a
|
||||||
// symlink to an original, with `json` (if given) as its sibling JSON.
|
// symlink into originals/, with `json` (if given) as its sibling JSON.
|
||||||
const backupWithUserFolder = async (
|
const backupWithUserFolder = async (
|
||||||
json: string | undefined,
|
json: string | undefined,
|
||||||
): Promise<{ outDir: string; failed: number }> => {
|
): Promise<{ outDir: string; failed: number }> => {
|
||||||
@@ -1100,7 +1019,10 @@ describe("backup album folders", () => {
|
|||||||
await runBackup(lib, { downloadDirectory: outDir });
|
await runBackup(lib, { downloadDirectory: outDir });
|
||||||
const collectionsDir = join(outDir, "collections");
|
const collectionsDir = join(outDir, "collections");
|
||||||
mkdirSync(join(collectionsDir, "Mine"));
|
mkdirSync(join(collectionsDir, "Mine"));
|
||||||
symlinkSync(linkTo("1.jpg"), join(collectionsDir, "Mine", "a.jpg"));
|
symlinkSync(
|
||||||
|
"../../originals/1.jpg",
|
||||||
|
join(collectionsDir, "Mine", "a.jpg"),
|
||||||
|
);
|
||||||
if (json !== undefined) {
|
if (json !== undefined) {
|
||||||
writeFileSync(join(collectionsDir, "Mine.json"), json);
|
writeFileSync(join(collectionsDir, "Mine.json"), json);
|
||||||
}
|
}
|
||||||
@@ -1114,9 +1036,9 @@ describe("backup album folders", () => {
|
|||||||
expect(failed).toBe(0);
|
expect(failed).toBe(0);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Mine/",
|
"Mine/",
|
||||||
`Mine/a.jpg -> ${linkTo("1.jpg")}`,
|
"Mine/a.jpg -> ../../originals/1.jpg",
|
||||||
"Trip/",
|
"Trip/",
|
||||||
`Trip/a.jpg -> ${linkTo("1.jpg")}`,
|
"Trip/a.jpg -> ../../originals/1.jpg",
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1128,10 +1050,10 @@ describe("backup album folders", () => {
|
|||||||
expect(failed).toBe(0);
|
expect(failed).toBe(0);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Mine/",
|
"Mine/",
|
||||||
`Mine/a.jpg -> ${linkTo("1.jpg")}`,
|
"Mine/a.jpg -> ../../originals/1.jpg",
|
||||||
"Mine.json",
|
"Mine.json",
|
||||||
"Trip/",
|
"Trip/",
|
||||||
`Trip/a.jpg -> ${linkTo("1.jpg")}`,
|
"Trip/a.jpg -> ../../originals/1.jpg",
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
@@ -1165,16 +1087,23 @@ describe("backup of live photos", () => {
|
|||||||
const open = (files: EnteFile[], bodies: Map<number, Uint8Array>) =>
|
const open = (files: EnteFile[], bodies: Map<number, Uint8Array>) =>
|
||||||
openLibrary(cdnSource(bodies), new TripClient(files));
|
openLibrary(cdnSource(bodies), new TripClient(files));
|
||||||
|
|
||||||
const stored = [
|
// What an earlier version stored for live photo 500: the ZIP under the
|
||||||
"2026-03-01.500.heic",
|
// image's name, and its link.
|
||||||
"2026-03-01.500.json",
|
const earlierZIP = (outDir: string): void => {
|
||||||
"2026-03-01.500.livephoto.json",
|
mkdirSync(join(outDir, "originals"), { recursive: true });
|
||||||
"2026-03-01.500.mov",
|
mkdirSync(join(outDir, "collections", "Trip"), { recursive: true });
|
||||||
];
|
writeFileSync(join(outDir, "originals", "500.HEIC"), livePhotoZip());
|
||||||
|
symlinkSync(
|
||||||
|
"../../originals/500.HEIC",
|
||||||
|
join(outDir, "collections", "Trip", "IMG_0500.HEIC"),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stored = ["500.heic", "500.json", "500.livephoto.json", "500.mov"];
|
||||||
const linked = [
|
const linked = [
|
||||||
"Trip/",
|
"Trip/",
|
||||||
`Trip/IMG_0500.heic -> ${linkTo("500.heic")}`,
|
"Trip/IMG_0500.heic -> ../../originals/500.heic",
|
||||||
`Trip/IMG_0500.mov -> ${linkTo("500.mov")}`,
|
"Trip/IMG_0500.mov -> ../../originals/500.mov",
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1184,15 +1113,16 @@ describe("backup of live photos", () => {
|
|||||||
);
|
);
|
||||||
const lib = await open([live], new Map([[500, body]]));
|
const lib = await open([live], new Map([[500, body]]));
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
|
const originals = join(outDir, "originals");
|
||||||
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
||||||
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
|
expect(readdirSync(originals).sort()).toEqual(stored);
|
||||||
expect(readFileSync(saved(outDir, "500.heic"))).toEqual(
|
expect(readFileSync(join(originals, "500.heic"))).toEqual(
|
||||||
Buffer.from(IMAGE),
|
Buffer.from(IMAGE),
|
||||||
);
|
);
|
||||||
expect(readFileSync(saved(outDir, "500.mov"))).toEqual(
|
expect(readFileSync(join(originals, "500.mov"))).toEqual(
|
||||||
Buffer.from(VIDEO),
|
Buffer.from(VIDEO),
|
||||||
);
|
);
|
||||||
expect(tree(outDir)).toEqual(linked);
|
expect(tree(outDir)).toEqual(linked);
|
||||||
@@ -1219,22 +1149,39 @@ describe("backup of live photos", () => {
|
|||||||
|
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual([
|
||||||
"Trip/",
|
"Trip/",
|
||||||
`Trip/IMG_0001 (500).heic -> ${linkTo("500.heic")}`,
|
"Trip/IMG_0001 (500).heic -> ../../originals/500.heic",
|
||||||
`Trip/IMG_0001 (500).mov -> ${linkTo("500.mov")}`,
|
"Trip/IMG_0001 (500).mov -> ../../originals/500.mov",
|
||||||
`Trip/IMG_0001 (501).heic -> ${linkTo("501.heic")}`,
|
"Trip/IMG_0001 (501).heic -> ../../originals/501.heic",
|
||||||
`Trip/IMG_0001 (501).mov -> ${linkTo("501.mov")}`,
|
"Trip/IMG_0001 (501).mov -> ../../originals/501.mov",
|
||||||
"Trip.json",
|
"Trip.json",
|
||||||
]);
|
]);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores nothing for a live photo that fails its hash", async () => {
|
it("replaces the ZIP an earlier version stored, and its link", async () => {
|
||||||
|
const { file: live, body } = await asLivePhoto(
|
||||||
|
file(500, 10, "IMG_0500.HEIC"),
|
||||||
|
);
|
||||||
|
const outDir = join(root, "backup");
|
||||||
|
earlierZIP(outDir);
|
||||||
|
const lib = await open([live], new Map([[500, body]]));
|
||||||
|
|
||||||
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
||||||
|
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
|
||||||
|
expect(tree(outDir)).toEqual(linked);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores nothing for a live photo that fails its hash, and keeps what was there", async () => {
|
||||||
const { file: live, body } = await asLivePhoto(
|
const { file: live, body } = await asLivePhoto(
|
||||||
file(500, 10, "IMG_0500.HEIC"),
|
file(500, 10, "IMG_0500.HEIC"),
|
||||||
livePhotoZip(),
|
livePhotoZip(),
|
||||||
"not:the recorded hash",
|
"not:the recorded hash",
|
||||||
);
|
);
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
|
earlierZIP(outDir);
|
||||||
const lib = await open([live], new Map([[500, body]]));
|
const lib = await open([live], new Map([[500, body]]));
|
||||||
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
@@ -1242,8 +1189,12 @@ describe("backup of live photos", () => {
|
|||||||
expect(result).toMatchObject({ downloaded: 0, failed: 1 });
|
expect(result).toMatchObject({ downloaded: 0, failed: 1 });
|
||||||
expect(result.errors.map((e) => e.fileID)).toEqual([500]);
|
expect(result.errors.map((e) => e.fileID)).toEqual([500]);
|
||||||
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
|
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
|
||||||
expect(readdirSync(join(outDir, DAY))).toEqual([]);
|
expect(readdirSync(join(outDir, "originals"))).toEqual(["500.HEIC"]);
|
||||||
expect(tree(outDir)).toEqual(["Trip/", "Trip.json"]);
|
expect(tree(outDir)).toEqual([
|
||||||
|
"Trip/",
|
||||||
|
"Trip/IMG_0500.HEIC -> ../../originals/500.HEIC",
|
||||||
|
"Trip.json",
|
||||||
|
]);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1258,8 +1209,8 @@ describe("backup of live photos", () => {
|
|||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
||||||
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
|
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
|
||||||
expect(readFileSync(saved(outDir, "500.mov"))).toEqual(
|
expect(readFileSync(join(outDir, "originals", "500.mov"))).toEqual(
|
||||||
Buffer.from(VIDEO),
|
Buffer.from(VIDEO),
|
||||||
);
|
);
|
||||||
expect(tree(outDir)).toEqual(linked);
|
expect(tree(outDir)).toEqual(linked);
|
||||||
@@ -1268,6 +1219,23 @@ describe("backup of live photos", () => {
|
|||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces an earlier ZIP and its link with the image and video the cache holds", async () => {
|
||||||
|
const { file: live, body } = await asLivePhoto(
|
||||||
|
file(500, 10, "IMG_0500.HEIC"),
|
||||||
|
);
|
||||||
|
const lib = await open([live], new Map([[500, body]]));
|
||||||
|
await lib.photos.byID({ fileID: 500 })!.original();
|
||||||
|
const outDir = join(root, "backup");
|
||||||
|
earlierZIP(outDir);
|
||||||
|
|
||||||
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
||||||
|
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
|
||||||
|
expect(tree(outDir)).toEqual(linked);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
it.each(["missing", "empty"])(
|
it.each(["missing", "empty"])(
|
||||||
"fetches a live photo again when the video its JSON file names is %s",
|
"fetches a live photo again when the video its JSON file names is %s",
|
||||||
async (state) => {
|
async (state) => {
|
||||||
@@ -1277,7 +1245,7 @@ describe("backup of live photos", () => {
|
|||||||
const lib = await open([live], new Map([[500, body]]));
|
const lib = await open([live], new Map([[500, body]]));
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
await lib.backup({ downloadDirectory: outDir });
|
await lib.backup({ downloadDirectory: outDir });
|
||||||
const video = saved(outDir, "500.mov");
|
const video = join(outDir, "originals", "500.mov");
|
||||||
if (state === "missing") rmSync(video);
|
if (state === "missing") rmSync(video);
|
||||||
else writeFileSync(video, "");
|
else writeFileSync(video, "");
|
||||||
|
|
||||||
@@ -1303,7 +1271,7 @@ describe("backup of live photos", () => {
|
|||||||
const lib = await open([live], new Map([[500, body]]));
|
const lib = await open([live], new Map([[500, body]]));
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
await lib.backup({ downloadDirectory: outDir });
|
await lib.backup({ downloadDirectory: outDir });
|
||||||
const image = saved(outDir, "500.heic");
|
const image = join(outDir, "originals", "500.heic");
|
||||||
if (state === "missing") rmSync(image);
|
if (state === "missing") rmSync(image);
|
||||||
else writeFileSync(image, "");
|
else writeFileSync(image, "");
|
||||||
|
|
||||||
@@ -1342,8 +1310,8 @@ describe("backup of live photos", () => {
|
|||||||
const read = await reader.photos.byID({ fileID: 500 })!.original();
|
const read = await reader.photos.byID({ fileID: 500 })!.original();
|
||||||
|
|
||||||
expect(read).toEqual({
|
expect(read).toEqual({
|
||||||
path: saved(outDir, "500.heic"),
|
path: join(outDir, "originals", "500.heic"),
|
||||||
videoPath: saved(outDir, "500.mov"),
|
videoPath: join(outDir, "originals", "500.mov"),
|
||||||
bytes: IMAGE.length,
|
bytes: IMAGE.length,
|
||||||
});
|
});
|
||||||
await reader.close();
|
await reader.close();
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ import type { ContentSource } from "../../src/library/content.js";
|
|||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
import { init, toBase64 } from "../../src/crypto/index.js";
|
import { init, toBase64 } from "../../src/crypto/index.js";
|
||||||
import { defaultCacheDirectory } from "../../src/library/index.js";
|
import { defaultCacheDirectory } from "../../src/library/index.js";
|
||||||
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
|
||||||
import {
|
import {
|
||||||
asLivePhoto,
|
asLivePhoto,
|
||||||
cdnSource,
|
cdnSource,
|
||||||
@@ -654,31 +653,6 @@ describe("a live photo", () => {
|
|||||||
height: 4,
|
height: 4,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("backup-metadata --exif records the EXIF of a HEIC image", async () => {
|
|
||||||
const dir = join(root, "dump");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await backupMetadataCommand(
|
|
||||||
context(await livePhotoClient(HEIC_WITH_EXIF)),
|
|
||||||
dir,
|
|
||||||
{ exif: true },
|
|
||||||
),
|
|
||||||
).toBe(0);
|
|
||||||
|
|
||||||
const record = JSON.parse(
|
|
||||||
readFileSync(
|
|
||||||
join(dir, "collections", "1-Vacation", "300.json"),
|
|
||||||
"utf-8",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(record.imageMetadata.exifError).toBeUndefined();
|
|
||||||
expect(record.imageMetadata.exif).toMatchObject({
|
|
||||||
Make: { value: ["Canon"] },
|
|
||||||
Model: { value: ["EOS R5"] },
|
|
||||||
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("backup", () => {
|
describe("backup", () => {
|
||||||
@@ -757,7 +731,7 @@ describe("backup", () => {
|
|||||||
expect(code).toBe(1);
|
expect(code).toBe(1);
|
||||||
expect(runText).toBe("quak: HTTP 401 from server\n");
|
expect(runText).toBe("quak: HTTP 401 from server\n");
|
||||||
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
||||||
expect(existsSync(dir)).toBe(false);
|
expect(existsSync(join(dir, "originals"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+73
-242
@@ -1,28 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for reading EXIF (`src/exif.ts`) and the image metadata
|
* Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`.
|
||||||
* `quak backup-metadata --exif` records.
|
|
||||||
*
|
*
|
||||||
* The originals come from users' libraries, so a truncated or corrupt file
|
* The originals come from users' libraries, so a truncated or corrupt JPEG
|
||||||
* must neither hang the read nor throw out of it: `readAllExifTags` gives `{}`,
|
* must neither hang the scan nor throw out of it, and a malformed file must be
|
||||||
* and `backup-metadata` tells an EXIF block it cannot read apart from a file
|
* told apart from one that simply has no EXIF: the record carries the reason in
|
||||||
* that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below
|
* `exifError`. Each input below is a short hand-built byte array.
|
||||||
* is a short hand-built byte array; the HEIC is a real file.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
readAllExifTags,
|
extractExifFromJpeg,
|
||||||
readExifTags,
|
extractImageMetadata,
|
||||||
readPhotoExif,
|
} from "../../src/metadata-backup.js";
|
||||||
} from "../../src/exif.js";
|
|
||||||
import { extractImageMetadata } from "../../src/metadata-backup.js";
|
|
||||||
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
|
||||||
|
|
||||||
const SOI = [0xff, 0xd8]; // start of image
|
const SOI = [0xff, 0xd8]; // start of image
|
||||||
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan
|
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
|
||||||
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
|
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
|
||||||
const APP0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
|
||||||
const ZERO_LENGTH_APP0 = [0xff, 0xe0, 0x00, 0x00];
|
|
||||||
|
|
||||||
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
|
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
|
||||||
const TIFF_ORIENTATION_6 = [
|
const TIFF_ORIENTATION_6 = [
|
||||||
@@ -31,87 +24,6 @@ const TIFF_ORIENTATION_6 = [
|
|||||||
0x00, 0x00,
|
0x00, 0x00,
|
||||||
];
|
];
|
||||||
|
|
||||||
// A big-endian TIFF block holding Orientation 6 and DateTimeOriginal
|
|
||||||
// "0000:00:00 00:00:00", which a camera with an unset clock writes.
|
|
||||||
const TIFF_UNSET_DATE = [
|
|
||||||
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
|
||||||
// The first IFD, at 8: two entries, then no next IFD.
|
|
||||||
...[0x00, 0x02],
|
|
||||||
// Orientation (0x0112), SHORT, 6.
|
|
||||||
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
|
|
||||||
// The Exif IFD's offset (0x8769), LONG, 38.
|
|
||||||
...[0x87, 0x69, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x26],
|
|
||||||
...[0x00, 0x00, 0x00, 0x00],
|
|
||||||
// The Exif IFD, at 38: one entry, then no next IFD.
|
|
||||||
...[0x00, 0x01],
|
|
||||||
// DateTimeOriginal (0x9003), 20 ASCII bytes at 56.
|
|
||||||
...[0x90, 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x38],
|
|
||||||
...[0x00, 0x00, 0x00, 0x00],
|
|
||||||
...new TextEncoder().encode("0000:00:00 00:00:00\0"), // at 56
|
|
||||||
];
|
|
||||||
|
|
||||||
// A big-endian TIFF block holding a GPSAltitude of 12.5 m and no
|
|
||||||
// GPSAltitudeRef.
|
|
||||||
const TIFF_ALTITUDE_WITHOUT_REF = [
|
|
||||||
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
|
||||||
// The first IFD, at 8: one entry, then no next IFD.
|
|
||||||
...[0x00, 0x01],
|
|
||||||
// The GPS IFD's offset (0x8825), LONG, 26.
|
|
||||||
...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a],
|
|
||||||
...[0x00, 0x00, 0x00, 0x00],
|
|
||||||
// The GPS IFD, at 26: one entry, then no next IFD.
|
|
||||||
...[0x00, 0x01],
|
|
||||||
// GPSAltitude (0x0006), one RATIONAL at 44.
|
|
||||||
...[0x00, 0x06, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2c],
|
|
||||||
...[0x00, 0x00, 0x00, 0x00],
|
|
||||||
...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44
|
|
||||||
];
|
|
||||||
|
|
||||||
// A big-endian TIFF block holding Orientation 6 and a Make whose value lies
|
|
||||||
// past the end of the file.
|
|
||||||
const TIFF_MAKE_PAST_END = [
|
|
||||||
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
|
||||||
// The first IFD, at 8: two entries, then no next IFD.
|
|
||||||
...[0x00, 0x02],
|
|
||||||
// Make (0x010f), 6 ASCII bytes at 4096.
|
|
||||||
...[0x01, 0x0f, 0x00, 0x02, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x10, 0x00],
|
|
||||||
// Orientation (0x0112), SHORT, 6.
|
|
||||||
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
|
|
||||||
...[0x00, 0x00, 0x00, 0x00],
|
|
||||||
];
|
|
||||||
|
|
||||||
// A big-endian TIFF block with one IFD entry that exifreader has no name for:
|
|
||||||
// tag 0xc000 (49152), SHORT, 7.
|
|
||||||
const TIFF_UNNAMED_TAG = [
|
|
||||||
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
|
||||||
// The first IFD, at 8: one entry, then no next IFD.
|
|
||||||
...[0x00, 0x01],
|
|
||||||
// Tag 0xc000, SHORT, 7.
|
|
||||||
...[0xc0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00],
|
|
||||||
...[0x00, 0x00, 0x00, 0x00],
|
|
||||||
];
|
|
||||||
|
|
||||||
// A big-endian TIFF block holding Orientation 6, and a thumbnail IFD holding
|
|
||||||
// its own Orientation 1 and a 4-byte JPEG thumbnail.
|
|
||||||
const TIFF_WITH_THUMBNAIL = [
|
|
||||||
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
|
||||||
// The first IFD, at 8: one entry, then the thumbnail IFD at 26.
|
|
||||||
...[0x00, 0x01],
|
|
||||||
// Orientation (0x0112), SHORT, 6.
|
|
||||||
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
|
|
||||||
...[0x00, 0x00, 0x00, 0x1a],
|
|
||||||
// The thumbnail IFD, at 26: three entries, then no next IFD.
|
|
||||||
...[0x00, 0x03],
|
|
||||||
// Orientation (0x0112), SHORT, 1.
|
|
||||||
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00],
|
|
||||||
// JPEGInterchangeFormat (0x0201), LONG: the thumbnail is at 68.
|
|
||||||
...[0x02, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x44],
|
|
||||||
// JPEGInterchangeFormatLength (0x0202), LONG: 4 bytes.
|
|
||||||
...[0x02, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04],
|
|
||||||
...[0x00, 0x00, 0x00, 0x00],
|
|
||||||
...[0xff, 0xd8, 0xff, 0xd9], // the thumbnail, at 68: an empty JPEG
|
|
||||||
];
|
|
||||||
|
|
||||||
// An APP1 segment whose length field matches its data.
|
// An APP1 segment whose length field matches its data.
|
||||||
const app1 = (data: number[]): number[] => {
|
const app1 = (data: number[]): number[] => {
|
||||||
const len = data.length + 2;
|
const len = data.length + 2;
|
||||||
@@ -121,129 +33,74 @@ const app1 = (data: number[]): number[] => {
|
|||||||
const bytes = (...parts: number[][]): Uint8Array =>
|
const bytes = (...parts: number[][]): Uint8Array =>
|
||||||
new Uint8Array(parts.flat());
|
new Uint8Array(parts.flat());
|
||||||
|
|
||||||
describe("readAllExifTags", () => {
|
describe("extractExifFromJpeg", () => {
|
||||||
it("keys a tag exifreader has no name for by its number", () => {
|
it("returns the EXIF segment of a valid JPEG", () => {
|
||||||
const data = [...EXIF_HEADER, ...TIFF_UNNAMED_TAG];
|
|
||||||
expect(readAllExifTags(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
|
||||||
"undefined-49152": {
|
|
||||||
id: 49152,
|
|
||||||
value: 7,
|
|
||||||
description: 7,
|
|
||||||
computed: 7,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("puts the thumbnail's tags under Thumbnail, without its image", () => {
|
|
||||||
const input = bytes(
|
|
||||||
SOI,
|
|
||||||
app1([...EXIF_HEADER, ...TIFF_WITH_THUMBNAIL]),
|
|
||||||
SOS,
|
|
||||||
);
|
|
||||||
// exifreader finds the thumbnail's image.
|
|
||||||
expect(readExifTags(input)?.Thumbnail?.type).toBe("image/jpeg");
|
|
||||||
const tags = readAllExifTags(input);
|
|
||||||
expect(tags.Orientation?.value).toBe(6);
|
|
||||||
expect(Object.keys(tags.Thumbnail ?? {}).sort()).toEqual([
|
|
||||||
"JPEGInterchangeFormat",
|
|
||||||
"JPEGInterchangeFormatLength",
|
|
||||||
"Orientation",
|
|
||||||
]);
|
|
||||||
expect(tags.Thumbnail?.Orientation?.value).toBe(1);
|
|
||||||
expect(readPhotoExif(tags)).toStrictEqual({ orientation: 6 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("readPhotoExif", () => {
|
|
||||||
it("reads the common fields of a valid JPEG", () => {
|
|
||||||
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
|
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
|
||||||
expect(
|
const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS));
|
||||||
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
|
expect(scan.error).toBeUndefined();
|
||||||
).toStrictEqual({
|
expect([...scan.exif!]).toEqual(data);
|
||||||
orientation: 6,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => {
|
it("returns nothing for a file that is not a JPEG", () => {
|
||||||
const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE];
|
const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
expect(
|
expect(extractExifFromJpeg(png)).toEqual({});
|
||||||
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
|
|
||||||
).toStrictEqual({
|
|
||||||
orientation: 6,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => {
|
it("returns nothing for a JPEG without EXIF", () => {
|
||||||
const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF];
|
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
||||||
expect(
|
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
|
||||||
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
|
|
||||||
).toStrictEqual({
|
|
||||||
gpsAltitude: 12.5,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives no make for a Make whose value lies past the end of the file", () => {
|
it("ignores an APP1 segment too short to hold the Exif header", () => {
|
||||||
const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END];
|
// A length under 8 cannot hold the six-byte "Exif\0\0" header, so the
|
||||||
expect(
|
// segment is not EXIF. This one has length 7 and holds only "Exif\0",
|
||||||
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
|
// which the old code, lacking the length check, returned as EXIF.
|
||||||
).toStrictEqual({
|
const short = app1(EXIF_HEADER.slice(0, 5));
|
||||||
orientation: 6,
|
expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it("accepts an APP1 segment of length 8 holding just the Exif header", () => {
|
||||||
[
|
const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS));
|
||||||
"a file that is not an image",
|
expect(scan.error).toBeUndefined();
|
||||||
new TextEncoder().encode("just some text, not an image"),
|
expect([...scan.exif!]).toEqual(EXIF_HEADER);
|
||||||
],
|
});
|
||||||
[
|
|
||||||
"a PNG without EXIF",
|
it("reports a JPEG truncated inside a segment header", () => {
|
||||||
bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00]));
|
||||||
],
|
expect(scan.exif).toBeUndefined();
|
||||||
["a JPEG without EXIF", bytes(SOI, APP0, SOS)],
|
expect(scan.error).toMatch(/truncated segment length/);
|
||||||
// A length under 8 cannot hold the six-byte "Exif\0\0" header. This one
|
});
|
||||||
// has length 7 and holds only "Exif\0", so a read past its end would
|
|
||||||
// take the next segment's bytes as EXIF.
|
it("reports a JPEG that ends before the image data", () => {
|
||||||
[
|
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
||||||
"an APP1 segment too short to hold the Exif header",
|
const scan = extractExifFromJpeg(bytes(SOI, app0));
|
||||||
bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS),
|
expect(scan.error).toMatch(/ends before the image data/);
|
||||||
],
|
});
|
||||||
[
|
|
||||||
"an APP1 segment holding just the Exif header",
|
it("stops on a zero-length segment instead of looping", () => {
|
||||||
bytes(SOI, app1(EXIF_HEADER), SOS),
|
// A length of 0 would otherwise step the scan by 2 bytes at a time
|
||||||
],
|
// through the rest of the file, reading garbage as markers.
|
||||||
[
|
const zero = [0xff, 0xe0, 0x00, 0x00];
|
||||||
"a JPEG truncated inside a segment header",
|
const scan = extractExifFromJpeg(
|
||||||
bytes(SOI, [0xff, 0xe1, 0x00]),
|
bytes(SOI, zero, zero, zero, zero, SOS),
|
||||||
],
|
);
|
||||||
["a JPEG that ends before the image data", bytes(SOI, APP0)],
|
expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/);
|
||||||
// A length of 0 would step a scan by 2 bytes at a time through the
|
});
|
||||||
// rest of the file, reading garbage as markers.
|
|
||||||
[
|
it("stops on a segment length of 1", () => {
|
||||||
"a zero-length segment",
|
const scan = extractExifFromJpeg(
|
||||||
bytes(
|
bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS),
|
||||||
SOI,
|
);
|
||||||
ZERO_LENGTH_APP0,
|
expect(scan.error).toMatch(/segment length 1 at byte 2 is too small/);
|
||||||
ZERO_LENGTH_APP0,
|
});
|
||||||
ZERO_LENGTH_APP0,
|
|
||||||
ZERO_LENGTH_APP0,
|
it("reports a segment length that runs past the end of the file", () => {
|
||||||
SOS,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
["a segment length of 1", bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS)],
|
|
||||||
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
|
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
|
||||||
[
|
const scan = extractExifFromJpeg(
|
||||||
"a segment length that runs past the end of the file",
|
|
||||||
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
||||||
],
|
);
|
||||||
// "XX" where the TIFF byte order belongs.
|
expect(scan.exif).toBeUndefined();
|
||||||
[
|
expect(scan.error).toMatch(/runs past the end of the file/);
|
||||||
"an EXIF block that cannot be parsed",
|
|
||||||
bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS),
|
|
||||||
],
|
|
||||||
])("returns no tags and no fields for %s", (_, input) => {
|
|
||||||
expect(readAllExifTags(input)).toStrictEqual({});
|
|
||||||
expect(readPhotoExif(readAllExifTags(input))).toStrictEqual({});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -253,30 +110,10 @@ describe("extractImageMetadata", () => {
|
|||||||
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
|
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
|
||||||
);
|
);
|
||||||
expect(meta?.exifError).toBeUndefined();
|
expect(meta?.exifError).toBeUndefined();
|
||||||
expect(meta?.exif).toMatchObject({ Orientation: { value: 6 } });
|
expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses EXIF from a HEIC", () => {
|
it("returns nothing for a file that is not a JPEG", () => {
|
||||||
const meta = extractImageMetadata(HEIC_WITH_EXIF);
|
|
||||||
expect(meta?.exifError).toBeUndefined();
|
|
||||||
expect(meta?.exif).toMatchObject({
|
|
||||||
Make: { value: ["Canon"] },
|
|
||||||
Model: { value: ["EOS R5"] },
|
|
||||||
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
|
|
||||||
Orientation: { value: 6 },
|
|
||||||
GPSLatitudeRef: { value: ["N"] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keys a tag exifreader has no name for by its number", () => {
|
|
||||||
const meta = extractImageMetadata(
|
|
||||||
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_UNNAMED_TAG]), SOS),
|
|
||||||
);
|
|
||||||
expect(meta?.exifError).toBeUndefined();
|
|
||||||
expect(meta?.exif).toMatchObject({ "undefined-49152": { value: 7 } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns nothing for a file that is not an image", () => {
|
|
||||||
const text = new TextEncoder().encode("just some text, not an image");
|
const text = new TextEncoder().encode("just some text, not an image");
|
||||||
expect(extractImageMetadata(text)).toBeUndefined();
|
expect(extractImageMetadata(text)).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -286,20 +123,14 @@ describe("extractImageMetadata", () => {
|
|||||||
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
||||||
);
|
);
|
||||||
expect(meta?.exif).toBeUndefined();
|
expect(meta?.exif).toBeUndefined();
|
||||||
expect(meta?.exifError).toBe(
|
expect(meta?.exifError).toMatch(/runs past the end of the file/);
|
||||||
"no tag could be read from the EXIF block",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => {
|
it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => {
|
||||||
// The raw bytes are the whole EXIF block as exifreader finds it: for a
|
const data = [...EXIF_HEADER, 0x58, 0x58];
|
||||||
// JPEG, the APP1 segment, marker and length included.
|
const meta = extractImageMetadata(bytes(SOI, app1(data), SOS));
|
||||||
const segment = app1([...EXIF_HEADER, 0x58, 0x58]);
|
|
||||||
const meta = extractImageMetadata(bytes(SOI, segment, SOS));
|
|
||||||
expect(meta?.exif).toBeUndefined();
|
expect(meta?.exif).toBeUndefined();
|
||||||
expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64"));
|
expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64"));
|
||||||
expect(meta?.exifError).toBe(
|
expect(meta?.exifError).toEqual(expect.any(String));
|
||||||
"no tag could be read from the EXIF block",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1883,6 +1883,15 @@ describe("downloadFile live photos", () => {
|
|||||||
expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]);
|
expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces what was at the destination, such as an earlier ZIP of the two", async () => {
|
||||||
|
const t = setup(livePhotoZip(), livePhoto);
|
||||||
|
writeFileSync(t.outPath, livePhotoZip());
|
||||||
|
|
||||||
|
await t.run();
|
||||||
|
|
||||||
|
expect(readdirSync(t.dir).sort()).toEqual(["f.heic", "f.mov"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("renames the image and then the video into place, each from its own temp file", async () => {
|
it("renames the image and then the video into place, each from its own temp file", async () => {
|
||||||
const t = setup(livePhotoZip(), livePhoto);
|
const t = setup(livePhotoZip(), livePhoto);
|
||||||
|
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
/**
|
|
||||||
* The example script `examples/download-albums.ts` (issue #144), run twice
|
|
||||||
* against a stand-in account, as a user would run it twice.
|
|
||||||
*
|
|
||||||
* The account has two albums sharing one photo, and one of its photos is a
|
|
||||||
* live photo whose image is a HEIC with EXIF. The first run puts every
|
|
||||||
* original at its save path, writes each photo's metadata beside it and each
|
|
||||||
* album's photos under `albums/`. Before the second run the account gains an
|
|
||||||
* album holding a new photo. The second run downloads that photo and writes
|
|
||||||
* its files and the new album's, and fetches nothing else and changes no
|
|
||||||
* other file.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
||||||
import {
|
|
||||||
existsSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
|
||||||
statSync,
|
|
||||||
utimesSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { downloadAlbums } from "../../examples/download-albums.js";
|
|
||||||
import { Library, type ContentSource } from "../../src/index.js";
|
|
||||||
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
|
||||||
import { readAllExifTags } from "../../src/exif.js";
|
|
||||||
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
|
||||||
import {
|
|
||||||
asLivePhoto,
|
|
||||||
cdnSource,
|
|
||||||
livePhotoHash,
|
|
||||||
livePhotoZip,
|
|
||||||
VIDEO,
|
|
||||||
} from "../live-photo.js";
|
|
||||||
|
|
||||||
const USER_ID = 7;
|
|
||||||
|
|
||||||
// Every photo is taken at noon local time on 2026-03-01, so the machine's time
|
|
||||||
// zone cannot move it to another day; it is saved in the folder `DAY`. Ente
|
|
||||||
// stores times in microseconds.
|
|
||||||
const TAKEN_MS = new Date(2026, 2, 1, 12).getTime();
|
|
||||||
const DAY = join("2026", "2026-03", "2026-03-01");
|
|
||||||
|
|
||||||
const collection = (id: number, name: string): Collection => ({
|
|
||||||
id,
|
|
||||||
ownerID: USER_ID,
|
|
||||||
key: new Uint8Array([id]),
|
|
||||||
name,
|
|
||||||
type: "album",
|
|
||||||
updationTime: 1,
|
|
||||||
isShared: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const file = (id: number, collectionID: number): EnteFile => ({
|
|
||||||
id,
|
|
||||||
collectionID,
|
|
||||||
ownerID: USER_ID,
|
|
||||||
key: new Uint8Array([id]),
|
|
||||||
metadata: {
|
|
||||||
title: `file-${id}.jpg`,
|
|
||||||
fileType: "image",
|
|
||||||
creationTime: TAKEN_MS * 1000,
|
|
||||||
modificationTime: TAKEN_MS * 1000,
|
|
||||||
},
|
|
||||||
file: { decryptionHeader: "aGVhZGVy" },
|
|
||||||
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
|
||||||
updationTime: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
let root: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
root = mkdtempSync(join(tmpdir(), "quak-download-albums-"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (root && existsSync(root))
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Every file and directory under `dir`, by path.
|
|
||||||
const entries = (dir: string): string[] =>
|
|
||||||
readdirSync(dir, { recursive: true, encoding: "utf-8" });
|
|
||||||
|
|
||||||
// Set the modification time of everything under `dir` to the epoch, so that
|
|
||||||
// anything written there afterwards has a later one, however soon it comes.
|
|
||||||
const backdate = (dir: string): void => {
|
|
||||||
for (const name of entries(dir)) utimesSync(join(dir, name), 0, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The modification time of everything under `dir`, by path.
|
|
||||||
const mtimes = (dir: string): Map<string, number> =>
|
|
||||||
new Map(
|
|
||||||
entries(dir).map((name) => [name, statSync(join(dir, name)).mtimeMs]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const readJSON = (path: string): unknown =>
|
|
||||||
JSON.parse(readFileSync(path, "utf-8"));
|
|
||||||
|
|
||||||
describe("examples/download-albums.ts", () => {
|
|
||||||
it("downloads every album's photos with their metadata, and on a second run only what the account gained", async () => {
|
|
||||||
// Album 1, "Trip", holds photos 1 and 2. Album 2, "Family", holds
|
|
||||||
// photo 2 and photo 3, a live photo.
|
|
||||||
const live = await asLivePhoto(
|
|
||||||
file(3, 2),
|
|
||||||
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
|
|
||||||
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
|
|
||||||
);
|
|
||||||
const collections = [collection(1, "Trip"), collection(2, "Family")];
|
|
||||||
const filesByAlbum = new Map<number, EnteFile[]>([
|
|
||||||
[1, [file(1, 1), file(2, 1)]],
|
|
||||||
[2, [file(2, 2), live.file]],
|
|
||||||
]);
|
|
||||||
const client = {
|
|
||||||
whoami: () => ({ email: "u@example.com", userID: USER_ID }),
|
|
||||||
collectionsSince: async (): Promise<CollectionsPage> => ({
|
|
||||||
collections: [...collections],
|
|
||||||
deleted: [],
|
|
||||||
cursor: 1,
|
|
||||||
}),
|
|
||||||
filesSince: async (args: {
|
|
||||||
collectionID: number;
|
|
||||||
}): Promise<FilesPage> => ({
|
|
||||||
files: filesByAlbum.get(args.collectionID) ?? [],
|
|
||||||
deleted: [],
|
|
||||||
cursor: 1,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Photo 3 comes from a stand-in server, encrypted as Ente serves a
|
|
||||||
// live photo. Any other original is a few bytes naming its photo.
|
|
||||||
// `calls` counts every fetch, thumbnails included.
|
|
||||||
const server = cdnSource(new Map([[3, live.body]]));
|
|
||||||
let calls = 0;
|
|
||||||
const source: ContentSource = {
|
|
||||||
original: async (args) => {
|
|
||||||
calls++;
|
|
||||||
if (args.file.id === 3) return server.original(args);
|
|
||||||
const bytes = `original-${args.file.id}`;
|
|
||||||
writeFileSync(args.destination, bytes);
|
|
||||||
return { bytesWritten: bytes.length };
|
|
||||||
},
|
|
||||||
thumbnail: async (args) => {
|
|
||||||
calls++;
|
|
||||||
return server.thumbnail(args);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const dir = join(root, "photos");
|
|
||||||
const open = (): Promise<Library> =>
|
|
||||||
Library.open({
|
|
||||||
client,
|
|
||||||
cacheDirectory: join(root, "cache"),
|
|
||||||
downloadDirectory: dir,
|
|
||||||
contentSource: source,
|
|
||||||
refreshIntervalSeconds: 3600,
|
|
||||||
precacheThumbnails: false,
|
|
||||||
precacheOriginals: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const first = await open();
|
|
||||||
// The cache already holds photo 1's original, so its record names a
|
|
||||||
// cache path, which the metadata leaves out. download() copies it
|
|
||||||
// from the cache rather than fetching it again.
|
|
||||||
await first.photos.byID({ fileID: 1 })!.original();
|
|
||||||
expect(await downloadAlbums(first, dir)).toEqual({
|
|
||||||
downloaded: 3,
|
|
||||||
alreadyLocal: 0,
|
|
||||||
});
|
|
||||||
await first.close();
|
|
||||||
expect(calls).toBe(3);
|
|
||||||
|
|
||||||
// Each original at its save path, a live photo as its image, its video
|
|
||||||
// and the file naming them, and each photo's metadata beside it.
|
|
||||||
const day = join(dir, DAY);
|
|
||||||
expect(readdirSync(day).sort()).toEqual([
|
|
||||||
"2026-03-01.1.jpg",
|
|
||||||
"2026-03-01.1.jpg.json",
|
|
||||||
"2026-03-01.2.jpg",
|
|
||||||
"2026-03-01.2.jpg.json",
|
|
||||||
"2026-03-01.3.heic",
|
|
||||||
"2026-03-01.3.heic.json",
|
|
||||||
"2026-03-01.3.livephoto.json",
|
|
||||||
"2026-03-01.3.mov",
|
|
||||||
]);
|
|
||||||
expect(readFileSync(join(day, "2026-03-01.1.jpg"), "utf-8")).toBe(
|
|
||||||
"original-1",
|
|
||||||
);
|
|
||||||
expect(readFileSync(join(day, "2026-03-01.2.jpg"), "utf-8")).toBe(
|
|
||||||
"original-2",
|
|
||||||
);
|
|
||||||
expect(readFileSync(join(day, "2026-03-01.3.heic"))).toEqual(
|
|
||||||
Buffer.from(HEIC_WITH_EXIF),
|
|
||||||
);
|
|
||||||
expect(readFileSync(join(day, "2026-03-01.3.mov"))).toEqual(
|
|
||||||
Buffer.from(VIDEO),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The metadata is the photo's record and its EXIF tags. The originals
|
|
||||||
// of photos 1 and 2 are not image data, so they have no EXIF tags.
|
|
||||||
// Photo 3's are every tag of its image, as `photo.exif()` returns
|
|
||||||
// them. `record` holds the fields the three records share.
|
|
||||||
const record = {
|
|
||||||
takenAt: TAKEN_MS,
|
|
||||||
modifiedAt: TAKEN_MS,
|
|
||||||
fileType: "image",
|
|
||||||
isArchived: false,
|
|
||||||
isHidden: false,
|
|
||||||
};
|
|
||||||
expect(readJSON(join(day, "2026-03-01.1.jpg.json"))).toEqual({
|
|
||||||
...record,
|
|
||||||
fileID: 1,
|
|
||||||
albumIDs: [1],
|
|
||||||
title: "file-1.jpg",
|
|
||||||
exif: {},
|
|
||||||
});
|
|
||||||
expect(readJSON(join(day, "2026-03-01.2.jpg.json"))).toEqual({
|
|
||||||
...record,
|
|
||||||
fileID: 2,
|
|
||||||
albumIDs: [1, 2],
|
|
||||||
title: "file-2.jpg",
|
|
||||||
exif: {},
|
|
||||||
});
|
|
||||||
expect(readJSON(join(day, "2026-03-01.3.heic.json"))).toEqual({
|
|
||||||
...record,
|
|
||||||
fileID: 3,
|
|
||||||
albumIDs: [2],
|
|
||||||
title: "file-3.jpg",
|
|
||||||
fileType: "livePhoto",
|
|
||||||
hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO),
|
|
||||||
exif: readAllExifTags(HEIC_WITH_EXIF),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Each album's photos, newest first, by save path relative to `dir`.
|
|
||||||
// Photo 2 is in both.
|
|
||||||
expect(readdirSync(join(dir, "albums")).sort()).toEqual([
|
|
||||||
"1.json",
|
|
||||||
"2.json",
|
|
||||||
]);
|
|
||||||
expect(readJSON(join(dir, "albums", "1.json"))).toEqual({
|
|
||||||
collectionID: 1,
|
|
||||||
name: "Trip",
|
|
||||||
savePaths: [
|
|
||||||
join(DAY, "2026-03-01.2.jpg"),
|
|
||||||
join(DAY, "2026-03-01.1.jpg"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(readJSON(join(dir, "albums", "2.json"))).toEqual({
|
|
||||||
collectionID: 2,
|
|
||||||
name: "Family",
|
|
||||||
savePaths: [
|
|
||||||
join(DAY, "2026-03-01.3.heic"),
|
|
||||||
join(DAY, "2026-03-01.2.jpg"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Before the second run the account gains album 3, "Garden", holding
|
|
||||||
// a new photo 4. The second run, with a newly opened library, opens
|
|
||||||
// the cache written by the first and still downloads photo 4. It finds
|
|
||||||
// the other photos already local and fetches nothing else.
|
|
||||||
collections.push(collection(3, "Garden"));
|
|
||||||
filesByAlbum.set(3, [file(4, 3)]);
|
|
||||||
backdate(dir);
|
|
||||||
const before = mtimes(dir);
|
|
||||||
const second = await open();
|
|
||||||
expect(await downloadAlbums(second, dir)).toEqual({
|
|
||||||
downloaded: 1,
|
|
||||||
alreadyLocal: 3,
|
|
||||||
});
|
|
||||||
await second.close();
|
|
||||||
expect(calls).toBe(4);
|
|
||||||
expect(readFileSync(join(day, "2026-03-01.4.jpg"), "utf-8")).toBe(
|
|
||||||
"original-4",
|
|
||||||
);
|
|
||||||
expect(readJSON(join(dir, "albums", "3.json"))).toEqual({
|
|
||||||
collectionID: 3,
|
|
||||||
name: "Garden",
|
|
||||||
savePaths: [join(DAY, "2026-03-01.4.jpg")],
|
|
||||||
});
|
|
||||||
|
|
||||||
// The second run adds only photo 4's files and album 3's, and
|
|
||||||
// rewrites, renames or removes no file from the first run. The two
|
|
||||||
// directories that gain a file are the only other changes.
|
|
||||||
const after = mtimes(dir);
|
|
||||||
expect(
|
|
||||||
[...after.keys()].filter((name) => !before.has(name)).sort(),
|
|
||||||
).toEqual([
|
|
||||||
join(DAY, "2026-03-01.4.jpg"),
|
|
||||||
join(DAY, "2026-03-01.4.jpg.json"),
|
|
||||||
join("albums", "3.json"),
|
|
||||||
]);
|
|
||||||
for (const [name, mtime] of before) {
|
|
||||||
if (name === DAY || name === "albums") continue;
|
|
||||||
expect(after.get(name), name).toBe(mtime);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/**
|
|
||||||
* `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same
|
|
||||||
* values as the hand-built JPEG in `library/content-library.test.ts`, for the
|
|
||||||
* tests of `exif()` and `backup-metadata --exif`.
|
|
||||||
*
|
|
||||||
* It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3),
|
|
||||||
* with libheif 1.23.0 and exiftool 13.55:
|
|
||||||
*
|
|
||||||
* apk add libheif-tools exiftool imagemagick
|
|
||||||
* magick -size 64x64 gradient:red-blue -depth 8 in.png
|
|
||||||
* heif-enc -q 30 -o exif.heic in.png
|
|
||||||
* exiftool -overwrite_original \
|
|
||||||
* -Make=Canon -Model="EOS R5" -LensModel="RF50mm F1.8 STM" \
|
|
||||||
* -DateTimeOriginal="2021:07:15 14:30:00" -OffsetTimeOriginal="+02:00" \
|
|
||||||
* -ExposureTime=1/250 -FNumber=2.8 -ISO=400 -FocalLength=50 \
|
|
||||||
* -Orientation#=6 \
|
|
||||||
* -GPSLatitude="40 26 46" -GPSLatitudeRef=N \
|
|
||||||
* -GPSLongitude="79 58 56" -GPSLongitudeRef=W \
|
|
||||||
* -GPSAltitude=12.5 -GPSAltitudeRef#=1 \
|
|
||||||
* exif.heic
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
|
|
||||||
export const HEIC_WITH_EXIF = new Uint8Array(
|
|
||||||
readFileSync(new URL("exif.heic", import.meta.url)),
|
|
||||||
);
|
|
||||||
Binary file not shown.
@@ -142,7 +142,6 @@ const buildCache = (args: {
|
|||||||
pools: new RequestPools(),
|
pools: new RequestPools(),
|
||||||
source,
|
source,
|
||||||
cacheDirectory: cacheDir,
|
cacheDirectory: cacheDir,
|
||||||
downloadDirectory: join(root, "photos"),
|
|
||||||
getFile: (id) => byID.get(id),
|
getFile: (id) => byID.get(id),
|
||||||
statfs: args.statfs,
|
statfs: args.statfs,
|
||||||
cacheOriginalsMaxBytes: args.cacheOriginalsMaxBytes,
|
cacheOriginalsMaxBytes: args.cacheOriginalsMaxBytes,
|
||||||
|
|||||||
@@ -6,18 +6,15 @@
|
|||||||
* `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and
|
* `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and
|
||||||
* a cached path shows up on the projected record. A library opened without a
|
* a cached path shows up on the projected record. A library opened without a
|
||||||
* content source leaves those methods throwing rather than silently doing
|
* content source leaves those methods throwing rather than silently doing
|
||||||
* nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `download()`,
|
* nothing.
|
||||||
* `content()`, `exif()` and the methods that each return one EXIF field.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
rmSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
statSync,
|
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -27,25 +24,10 @@ import { Library, type LibraryOptions } from "../../src/library/index.js";
|
|||||||
import type { ContentSource } from "../../src/library/content.js";
|
import type { ContentSource } from "../../src/library/content.js";
|
||||||
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
import { readPhotoExif, type PhotoExif } from "../../src/exif.js";
|
import { asLivePhoto, cdnSource, livePhotoZip } from "../live-photo.js";
|
||||||
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
|
||||||
import {
|
|
||||||
asLivePhoto,
|
|
||||||
cdnSource,
|
|
||||||
IMAGE,
|
|
||||||
livePhotoHash,
|
|
||||||
livePhotoZip,
|
|
||||||
VIDEO,
|
|
||||||
} from "../live-photo.js";
|
|
||||||
|
|
||||||
const USER_ID = 7;
|
const USER_ID = 7;
|
||||||
|
|
||||||
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
|
|
||||||
// stores times, so the machine's time zone cannot move it to another day; it
|
|
||||||
// is saved in the folder `DAY`.
|
|
||||||
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
|
|
||||||
const DAY = join("2026", "2026-03", "2026-03-01");
|
|
||||||
|
|
||||||
const collection = (id: number): Collection => ({
|
const collection = (id: number): Collection => ({
|
||||||
id,
|
id,
|
||||||
ownerID: USER_ID,
|
ownerID: USER_ID,
|
||||||
@@ -64,7 +46,7 @@ const file = (id: number, collectionID: number): EnteFile => ({
|
|||||||
metadata: {
|
metadata: {
|
||||||
title: `file-${id}.jpg`,
|
title: `file-${id}.jpg`,
|
||||||
fileType: "image",
|
fileType: "image",
|
||||||
creationTime: TAKEN,
|
creationTime: 1,
|
||||||
modificationTime: 1,
|
modificationTime: 1,
|
||||||
},
|
},
|
||||||
file: { decryptionHeader: "aGVhZGVy" },
|
file: { decryptionHeader: "aGVhZGVy" },
|
||||||
@@ -88,23 +70,14 @@ class MockClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A content source that writes `original` as every original and a marker file
|
// A content source that writes a marker file and counts thumbnail fetches.
|
||||||
// as every thumbnail, and counts the fetches of each.
|
const stubSource = (): ContentSource & { thumbCalls: () => number } => {
|
||||||
const stubSource = (
|
|
||||||
original: string | Uint8Array = "orig-bytes",
|
|
||||||
): ContentSource & {
|
|
||||||
originalCalls: () => number;
|
|
||||||
thumbCalls: () => number;
|
|
||||||
} => {
|
|
||||||
let originalCalls = 0;
|
|
||||||
let thumbCalls = 0;
|
let thumbCalls = 0;
|
||||||
return {
|
return {
|
||||||
originalCalls: () => originalCalls,
|
|
||||||
thumbCalls: () => thumbCalls,
|
thumbCalls: () => thumbCalls,
|
||||||
original: async ({ destination }) => {
|
original: async ({ destination }) => {
|
||||||
originalCalls++;
|
writeFileSync(destination, "orig-bytes");
|
||||||
writeFileSync(destination, original);
|
return { bytesWritten: 10 };
|
||||||
return { bytesWritten: original.length };
|
|
||||||
},
|
},
|
||||||
thumbnail: async ({ destination }) => {
|
thumbnail: async ({ destination }) => {
|
||||||
thumbCalls++;
|
thumbCalls++;
|
||||||
@@ -176,7 +149,7 @@ describe("Library content wiring", () => {
|
|||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not take a live photo's image or video with no JSON file as its original when it opens, and precaches both", async () => {
|
it("removes a live photo's ZIP an earlier version cached when it opens, and precaches its image and video", async () => {
|
||||||
const { file: live, body } = await asLivePhoto(file(1, 1));
|
const { file: live, body } = await asLivePhoto(file(1, 1));
|
||||||
class LiveClient extends MockClient {
|
class LiveClient extends MockClient {
|
||||||
override async filesSince(): Promise<FilesPage> {
|
override async filesSince(): Promise<FilesPage> {
|
||||||
@@ -197,8 +170,7 @@ describe("Library content wiring", () => {
|
|||||||
// A first run records the library, so the next one knows that file 1
|
// A first run records the library, so the next one knows that file 1
|
||||||
// is a live photo when it opens the cache.
|
// is a live photo when it opens the cache.
|
||||||
await (await open({})).close();
|
await (await open({})).close();
|
||||||
writeFileSync(join(originals, "1.heic"), "an image");
|
writeFileSync(join(originals, "1.jpg"), livePhotoZip());
|
||||||
writeFileSync(join(originals, "1.mov"), "a video");
|
|
||||||
|
|
||||||
let precached!: () => void;
|
let precached!: () => void;
|
||||||
const done = new Promise<void>((r) => (precached = r));
|
const done = new Promise<void>((r) => (precached = r));
|
||||||
@@ -209,9 +181,7 @@ describe("Library content wiring", () => {
|
|||||||
precached();
|
precached();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(
|
expect(existsSync(join(originals, "1.jpg"))).toBe(false);
|
||||||
lib.photos.byID({ fileID: 1 })!.record().originalPath,
|
|
||||||
).toBeUndefined();
|
|
||||||
await done;
|
await done;
|
||||||
|
|
||||||
expect(readdirSync(originals).sort()).toEqual([
|
expect(readdirSync(originals).sort()).toEqual([
|
||||||
@@ -219,17 +189,6 @@ describe("Library content wiring", () => {
|
|||||||
"1.livephoto.json",
|
"1.livephoto.json",
|
||||||
"1.mov",
|
"1.mov",
|
||||||
]);
|
]);
|
||||||
expect(readFileSync(join(originals, "1.heic"))).toEqual(
|
|
||||||
Buffer.from(IMAGE),
|
|
||||||
);
|
|
||||||
expect(readFileSync(join(originals, "1.mov"))).toEqual(
|
|
||||||
Buffer.from(VIDEO),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
JSON.parse(
|
|
||||||
readFileSync(join(originals, "1.livephoto.json"), "utf-8"),
|
|
||||||
),
|
|
||||||
).toEqual({ image: "1.heic", video: "1.mov" });
|
|
||||||
expect(lib.photos.byID({ fileID: 1 })!.record().originalPath).toBe(
|
expect(lib.photos.byID({ fileID: 1 })!.record().originalPath).toBe(
|
||||||
join(originals, "1.heic"),
|
join(originals, "1.heic"),
|
||||||
);
|
);
|
||||||
@@ -246,584 +205,9 @@ describe("Library content wiring", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
lib.photos.byID({ fileID: 1 })!.thumbnail(),
|
lib.photos.byID({ fileID: 1 })!.thumbnail(),
|
||||||
).rejects.toThrow(/content cache/i);
|
).rejects.toThrow(/content cache/i);
|
||||||
await expect(
|
|
||||||
lib.photos.byID({ fileID: 1 })!.download(),
|
|
||||||
).rejects.toThrow(/content cache/i);
|
|
||||||
await expect(
|
await expect(
|
||||||
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
|
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
|
||||||
).rejects.toThrow(/content cache/i);
|
).rejects.toThrow(/content cache/i);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Big-endian bytes for the hand-built JPEG below.
|
|
||||||
const u16 = (n: number): number[] => [n >> 8, n & 0xff];
|
|
||||||
const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)];
|
|
||||||
const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0];
|
|
||||||
const rational = (num: number, den: number): number[] => [
|
|
||||||
...u32(num),
|
|
||||||
...u32(den),
|
|
||||||
];
|
|
||||||
// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL),
|
|
||||||
// count, then the value when it fits in 4 bytes, else its offset.
|
|
||||||
const entry = (
|
|
||||||
tag: number,
|
|
||||||
type: number,
|
|
||||||
count: number,
|
|
||||||
value: number[],
|
|
||||||
): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value];
|
|
||||||
|
|
||||||
// The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed
|
|
||||||
// methods return: the camera in the first IFD, the exposure in the Exif IFD,
|
|
||||||
// and a GPS position of 40°26'46" N, 79°58'56" W, 12.5 m below sea level.
|
|
||||||
// Offsets count from the start of this block.
|
|
||||||
const TIFF = [
|
|
||||||
...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF
|
|
||||||
...u32(8), // the first IFD's offset
|
|
||||||
// The first IFD, at 8: five entries, then no next IFD.
|
|
||||||
...u16(5),
|
|
||||||
...entry(0x010f, 2, 6, u32(74)), // Make
|
|
||||||
...entry(0x0110, 2, 7, u32(80)), // Model
|
|
||||||
...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation
|
|
||||||
...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset
|
|
||||||
...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset
|
|
||||||
...u32(0),
|
|
||||||
...ascii("Canon"), // at 74
|
|
||||||
...ascii("EOS R5"), // at 80
|
|
||||||
0, // a pad byte
|
|
||||||
// The Exif IFD, at 88: seven entries, then no next IFD.
|
|
||||||
...u16(7),
|
|
||||||
...entry(0x829a, 5, 1, u32(178)), // ExposureTime
|
|
||||||
...entry(0x829d, 5, 1, u32(186)), // FNumber
|
|
||||||
...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings
|
|
||||||
...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal
|
|
||||||
...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal
|
|
||||||
...entry(0x920a, 5, 1, u32(222)), // FocalLength
|
|
||||||
...entry(0xa434, 2, 16, u32(230)), // LensModel
|
|
||||||
...u32(0),
|
|
||||||
...rational(1, 250), // at 178
|
|
||||||
...rational(28, 10), // at 186
|
|
||||||
...ascii("2021:07:15 14:30:00"), // at 194
|
|
||||||
...ascii("+02:00"), // at 214
|
|
||||||
0, // a pad byte
|
|
||||||
...rational(50, 1), // at 222
|
|
||||||
...ascii("RF50mm F1.8 STM"), // at 230
|
|
||||||
// The GPS IFD, at 246: six entries, then no next IFD.
|
|
||||||
...u16(6),
|
|
||||||
...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef
|
|
||||||
...entry(0x0002, 5, 3, u32(324)), // GPSLatitude
|
|
||||||
...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef
|
|
||||||
...entry(0x0004, 5, 3, u32(348)), // GPSLongitude
|
|
||||||
...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level
|
|
||||||
...entry(0x0006, 5, 1, u32(372)), // GPSAltitude
|
|
||||||
...u32(0),
|
|
||||||
...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324
|
|
||||||
...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348
|
|
||||||
...rational(25, 2), // at 372
|
|
||||||
];
|
|
||||||
|
|
||||||
const JPEG_WITH_EXIF = new Uint8Array([
|
|
||||||
...[0xff, 0xd8], // start of image
|
|
||||||
...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length
|
|
||||||
...[...ascii("Exif"), 0], // "Exif\0\0"
|
|
||||||
...TIFF,
|
|
||||||
...[0xff, 0xda, 0x00, 0x02], // start of scan
|
|
||||||
]);
|
|
||||||
|
|
||||||
// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF
|
|
||||||
// byte order belongs, so exifreader cannot parse it.
|
|
||||||
const JPEG_WITH_BAD_EXIF = new Uint8Array([
|
|
||||||
...[0xff, 0xd8], // start of image
|
|
||||||
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length
|
|
||||||
...[...ascii("Exif"), 0], // "Exif\0\0"
|
|
||||||
...[0x58, 0x58], // "XX"
|
|
||||||
...[0xff, 0xda, 0x00, 0x02], // start of scan
|
|
||||||
]);
|
|
||||||
|
|
||||||
describe("Photo save path, local copy, content and EXIF", () => {
|
|
||||||
// The same account, with `files` in its album instead.
|
|
||||||
class FilesClient extends MockClient {
|
|
||||||
constructor(private readonly files: EnteFile[]) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
override async filesSince(): Promise<FilesPage> {
|
|
||||||
return { files: this.files, deleted: [], cursor: 1 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const open = (opts: Partial<LibraryOptions> = {}): Promise<Library> =>
|
|
||||||
Library.open({
|
|
||||||
client: new MockClient(),
|
|
||||||
cacheDirectory: join(root, "cache"),
|
|
||||||
downloadDirectory: join(root, "backup"),
|
|
||||||
contentSource: stubSource(),
|
|
||||||
refreshIntervalSeconds: 3600,
|
|
||||||
precacheThumbnails: false,
|
|
||||||
precacheOriginals: false,
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names where a backup writes the original, which is local once the backup has written it", async () => {
|
|
||||||
const lib = await open();
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
const savePath = join(root, "backup", DAY, "2026-03-01.1.jpg");
|
|
||||||
expect(photo.savePath).toBe(savePath);
|
|
||||||
expect(photo.isLocal).toBe(false);
|
|
||||||
|
|
||||||
await lib.backup();
|
|
||||||
expect(photo.savePath).toBe(savePath);
|
|
||||||
expect(existsSync(savePath)).toBe(true);
|
|
||||||
expect(photo.isLocal).toBe(true);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the original's bytes, and a copy only in the cache is not local", async () => {
|
|
||||||
const lib = await open();
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
expect(await photo.content()).toEqual(Buffer.from("orig-bytes"));
|
|
||||||
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(existsSync(photo.savePath)).toBe(false);
|
|
||||||
expect(photo.isLocal).toBe(false);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves under photos/ in the working directory at open without a download directory", async () => {
|
|
||||||
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
||||||
const lib = await open({ downloadDirectory: undefined });
|
|
||||||
cwd.mockRestore();
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
expect(lib.downloadDirectory).toBe(join(root, "photos"));
|
|
||||||
expect(photo.savePath).toBe(
|
|
||||||
join(root, "photos", DAY, "2026-03-01.1.jpg"),
|
|
||||||
);
|
|
||||||
expect(photo.isLocal).toBe(false);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses an empty download directory", async () => {
|
|
||||||
await expect(open({ downloadDirectory: "" })).rejects.toThrow(
|
|
||||||
/downloadDirectory is empty/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a photo's save path after a refresh removes its file", async () => {
|
|
||||||
// The same account, whose album is deleted on the second refresh.
|
|
||||||
class AlbumDeletedClient extends MockClient {
|
|
||||||
override async collectionsSince(): Promise<CollectionsPage> {
|
|
||||||
if (!this.served) return super.collectionsSince();
|
|
||||||
return { collections: [], deleted: [1], cursor: 2 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const lib = await open({ client: new AlbumDeletedClient() });
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
await photo.download();
|
|
||||||
|
|
||||||
await lib.fresh();
|
|
||||||
|
|
||||||
expect(lib.photos.byID({ fileID: 1 })).toBeUndefined();
|
|
||||||
expect(photo.savePath).toBe(
|
|
||||||
join(root, "backup", DAY, "2026-03-01.1.jpg"),
|
|
||||||
);
|
|
||||||
expect(photo.isLocal).toBe(true);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dates the save path and download() by the membership its takenAt comes from", async () => {
|
|
||||||
// One file in two albums. The date edited in Ente has reached album 2's
|
|
||||||
// copy, synced later, but album 1 still has an earlier edit.
|
|
||||||
const older = file(1, 1);
|
|
||||||
older.pubMagicMetadata = {
|
|
||||||
editedTime: new Date(2026, 1, 1, 12).getTime() * 1000,
|
|
||||||
};
|
|
||||||
const newer = file(1, 2);
|
|
||||||
newer.updationTime = 2;
|
|
||||||
newer.pubMagicMetadata = {
|
|
||||||
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
|
|
||||||
};
|
|
||||||
const client = {
|
|
||||||
whoami: () => ({ email: "u@example.com", userID: USER_ID }),
|
|
||||||
collectionsSince: async (): Promise<CollectionsPage> => ({
|
|
||||||
collections: [collection(1), collection(2)],
|
|
||||||
deleted: [],
|
|
||||||
cursor: 1,
|
|
||||||
}),
|
|
||||||
filesSince: async (args: {
|
|
||||||
collectionID: number;
|
|
||||||
}): Promise<FilesPage> => ({
|
|
||||||
files: [args.collectionID === 1 ? older : newer],
|
|
||||||
deleted: [],
|
|
||||||
cursor: 1,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const lib = await open({ client });
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
|
|
||||||
expect(photo.takenAt).toBe(new Date(2026, 3, 15, 12).getTime());
|
|
||||||
expect(photo.savePath).toBe(
|
|
||||||
join(
|
|
||||||
root,
|
|
||||||
"backup",
|
|
||||||
"2026",
|
|
||||||
"2026-04",
|
|
||||||
"2026-04-15",
|
|
||||||
"2026-04-15.1.jpg",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// download() writes to that same path, so the photo is then local.
|
|
||||||
const saved = await photo.download();
|
|
||||||
expect(saved.path).toBe(photo.savePath);
|
|
||||||
expect(photo.isLocal).toBe(true);
|
|
||||||
expect(existsSync(join(root, "backup", "2026", "2026-02"))).toBe(false);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("downloads a photo held across a refresh that edits its date to the save path it names", async () => {
|
|
||||||
// The same account, whose second refresh brings a date edited in Ente.
|
|
||||||
const edited = file(1, 1);
|
|
||||||
edited.updationTime = 2;
|
|
||||||
edited.pubMagicMetadata = {
|
|
||||||
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
|
|
||||||
};
|
|
||||||
class DateEditedClient extends MockClient {
|
|
||||||
refreshes = 0;
|
|
||||||
override async collectionsSince(): Promise<CollectionsPage> {
|
|
||||||
this.refreshes++;
|
|
||||||
return {
|
|
||||||
collections: [
|
|
||||||
{ ...collection(1), updationTime: this.refreshes },
|
|
||||||
],
|
|
||||||
deleted: [],
|
|
||||||
cursor: this.refreshes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
override async filesSince(): Promise<FilesPage> {
|
|
||||||
return {
|
|
||||||
files: [this.refreshes === 1 ? file(1, 1) : edited],
|
|
||||||
deleted: [],
|
|
||||||
cursor: this.refreshes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const lib = await open({ client: new DateEditedClient() });
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
|
|
||||||
await lib.fresh();
|
|
||||||
expect(lib.photos.byID({ fileID: 1 })!.takenAt).toBe(
|
|
||||||
new Date(2026, 3, 15, 12).getTime(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const saved = await photo.download();
|
|
||||||
expect(saved.path).toBe(photo.savePath);
|
|
||||||
expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg"));
|
|
||||||
expect(photo.isLocal).toBe(true);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has a save path, and is not local, without a content source", async () => {
|
|
||||||
const lib = await open({ contentSource: undefined });
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
expect(photo.savePath).toBe(
|
|
||||||
join(root, "backup", DAY, "2026-03-01.1.jpg"),
|
|
||||||
);
|
|
||||||
expect(photo.isLocal).toBe(false);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives a live photo's image as its save path once a backup has stored it", async () => {
|
|
||||||
const { file: live, body } = await asLivePhoto(file(1, 1));
|
|
||||||
const lib = await open({
|
|
||||||
client: new FilesClient([live]),
|
|
||||||
contentSource: cdnSource(new Map([[1, body]])),
|
|
||||||
});
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
const day = join(root, "backup", DAY);
|
|
||||||
// Until then the name comes from the title, file-1.jpg; the backup
|
|
||||||
// stores the image with the extension found inside the live photo.
|
|
||||||
expect(photo.savePath).toBe(join(day, "2026-03-01.1.jpg"));
|
|
||||||
|
|
||||||
await lib.backup();
|
|
||||||
expect(photo.savePath).toBe(join(day, "2026-03-01.1.heic"));
|
|
||||||
expect(photo.isLocal).toBe(true);
|
|
||||||
expect(await photo.content()).toEqual(Buffer.from(IMAGE));
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("downloads an original the cache holds by copying it, without fetching it again", async () => {
|
|
||||||
const source = stubSource();
|
|
||||||
const lib = await open({ contentSource: source });
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
await photo.original();
|
|
||||||
expect(source.originalCalls()).toBe(1);
|
|
||||||
expect(photo.isLocal).toBe(false);
|
|
||||||
|
|
||||||
const saved = await photo.download();
|
|
||||||
|
|
||||||
expect(source.originalCalls()).toBe(1);
|
|
||||||
expect(saved).toEqual({
|
|
||||||
path: photo.savePath,
|
|
||||||
bytes: "orig-bytes".length,
|
|
||||||
});
|
|
||||||
expect(readFileSync(photo.savePath, "utf-8")).toBe("orig-bytes");
|
|
||||||
expect(photo.isLocal).toBe(true);
|
|
||||||
// The cache keeps its own copy.
|
|
||||||
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("downloads an original the cache does not hold straight to its save path", async () => {
|
|
||||||
const source = stubSource();
|
|
||||||
const lib = await open({ contentSource: source });
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
|
|
||||||
const saved = await photo.download();
|
|
||||||
|
|
||||||
expect(source.originalCalls()).toBe(1);
|
|
||||||
expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg"));
|
|
||||||
expect(readFileSync(saved.path, "utf-8")).toBe("orig-bytes");
|
|
||||||
expect(photo.isLocal).toBe(true);
|
|
||||||
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when download() finds the original already at its save path", async () => {
|
|
||||||
const source = stubSource();
|
|
||||||
const lib = await open({ contentSource: source });
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
const first = await photo.download();
|
|
||||||
const written = statSync(first.path).ino;
|
|
||||||
|
|
||||||
const second = await photo.download();
|
|
||||||
|
|
||||||
expect(second).toEqual(first);
|
|
||||||
expect(source.originalCalls()).toBe(1);
|
|
||||||
expect(statSync(second.path).ino).toBe(written);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("downloads a live photo the cache holds as its image, its video and the JSON file naming them", async () => {
|
|
||||||
const { file: live, body } = await asLivePhoto(file(1, 1));
|
|
||||||
const lib = await open({
|
|
||||||
client: new FilesClient([live]),
|
|
||||||
contentSource: cdnSource(new Map([[1, body]])),
|
|
||||||
});
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
await photo.original();
|
|
||||||
const day = join(root, "backup", DAY);
|
|
||||||
|
|
||||||
const saved = await photo.download();
|
|
||||||
|
|
||||||
expect(saved).toEqual({
|
|
||||||
path: join(day, "2026-03-01.1.heic"),
|
|
||||||
videoPath: join(day, "2026-03-01.1.mov"),
|
|
||||||
bytes: IMAGE.length,
|
|
||||||
});
|
|
||||||
expect(readdirSync(day).sort()).toEqual([
|
|
||||||
"2026-03-01.1.heic",
|
|
||||||
"2026-03-01.1.livephoto.json",
|
|
||||||
"2026-03-01.1.mov",
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
JSON.parse(
|
|
||||||
readFileSync(join(day, "2026-03-01.1.livephoto.json"), "utf-8"),
|
|
||||||
),
|
|
||||||
).toEqual({ image: "2026-03-01.1.heic", video: "2026-03-01.1.mov" });
|
|
||||||
expect(photo.savePath).toBe(saved.path);
|
|
||||||
expect(photo.isLocal).toBe(true);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Every tag in JPEG_WITH_EXIF, by name, in the order of its IFDs.
|
|
||||||
const jpegTags = [
|
|
||||||
"Make",
|
|
||||||
"Model",
|
|
||||||
"Orientation",
|
|
||||||
"Exif IFD Pointer",
|
|
||||||
"GPS Info IFD Pointer",
|
|
||||||
"ExposureTime",
|
|
||||||
"FNumber",
|
|
||||||
"ISOSpeedRatings",
|
|
||||||
"DateTimeOriginal",
|
|
||||||
"OffsetTimeOriginal",
|
|
||||||
"FocalLength",
|
|
||||||
"LensModel",
|
|
||||||
"GPSLatitudeRef",
|
|
||||||
"GPSLatitude",
|
|
||||||
"GPSLongitudeRef",
|
|
||||||
"GPSLongitude",
|
|
||||||
"GPSAltitudeRef",
|
|
||||||
"GPSAltitude",
|
|
||||||
];
|
|
||||||
// HEIC_WITH_EXIF holds those and the tags exiftool adds to every file.
|
|
||||||
const heicTags = [
|
|
||||||
...jpegTags,
|
|
||||||
"YCbCrPositioning",
|
|
||||||
"ExifVersion",
|
|
||||||
"ComponentsConfiguration",
|
|
||||||
"ColorSpace",
|
|
||||||
"GPSVersionID",
|
|
||||||
];
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[
|
|
||||||
"JPEG",
|
|
||||||
JPEG_WITH_EXIF,
|
|
||||||
jpegTags,
|
|
||||||
{
|
|
||||||
"Exif IFD Pointer": { value: 88 },
|
|
||||||
GPSLatitudeRef: { value: ["N"], description: "North latitude" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"HEIC",
|
|
||||||
HEIC_WITH_EXIF,
|
|
||||||
heicTags,
|
|
||||||
{
|
|
||||||
ColorSpace: { value: 0xffff, description: "Uncalibrated" },
|
|
||||||
ExifVersion: { description: "0232" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
])(
|
|
||||||
"returns every EXIF tag of a %s original, by name",
|
|
||||||
async (_, bytes, names, others) => {
|
|
||||||
const lib = await open({ contentSource: stubSource(bytes) });
|
|
||||||
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
|
||||||
expect(Object.keys(exif).sort()).toEqual([...names].sort());
|
|
||||||
// Tags outside the thirteen fields, as exifreader decodes them.
|
|
||||||
expect(exif).toMatchObject(others);
|
|
||||||
await lib.close();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("picks the common EXIF fields from a JPEG original's tags", async () => {
|
|
||||||
const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) });
|
|
||||||
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
|
||||||
expect(readPhotoExif(exif)).toStrictEqual({
|
|
||||||
make: "Canon",
|
|
||||||
model: "EOS R5",
|
|
||||||
lensModel: "RF50mm F1.8 STM",
|
|
||||||
// The camera's clock reading, held in the Date's UTC fields.
|
|
||||||
dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)),
|
|
||||||
offsetTimeOriginal: "+02:00",
|
|
||||||
exposureTime: 1 / 250,
|
|
||||||
fNumber: 2.8,
|
|
||||||
iso: 400,
|
|
||||||
focalLength: 50,
|
|
||||||
orientation: 6,
|
|
||||||
gpsLatitude: 40 + 26 / 60 + 46 / 3600,
|
|
||||||
gpsLongitude: -(79 + 58 / 60 + 56 / 3600),
|
|
||||||
gpsAltitude: -12.5,
|
|
||||||
});
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The fields picked from HEIC_WITH_EXIF's tags, and from JPEG_WITH_EXIF's,
|
|
||||||
// which hold the same values.
|
|
||||||
const heicFields: PhotoExif = {
|
|
||||||
make: "Canon",
|
|
||||||
model: "EOS R5",
|
|
||||||
lensModel: "RF50mm F1.8 STM",
|
|
||||||
dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)),
|
|
||||||
offsetTimeOriginal: "+02:00",
|
|
||||||
exposureTime: 1 / 250,
|
|
||||||
fNumber: 2.8,
|
|
||||||
iso: 400,
|
|
||||||
focalLength: 50,
|
|
||||||
orientation: 6,
|
|
||||||
gpsLatitude: 40 + 26 / 60 + 46 / 3600,
|
|
||||||
gpsLongitude: -(79 + 58 / 60 + 56 / 3600),
|
|
||||||
gpsAltitude: -12.5,
|
|
||||||
};
|
|
||||||
|
|
||||||
it("picks the same common EXIF fields from a HEIC original's tags", async () => {
|
|
||||||
const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) });
|
|
||||||
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
|
||||||
expect(readPhotoExif(exif)).toStrictEqual(heicFields);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads the EXIF of a live photo whose image is a HEIC", async () => {
|
|
||||||
const { file: live, body } = await asLivePhoto(
|
|
||||||
file(1, 1),
|
|
||||||
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
|
|
||||||
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
|
|
||||||
);
|
|
||||||
const lib = await open({
|
|
||||||
client: new FilesClient([live]),
|
|
||||||
contentSource: cdnSource(new Map([[1, body]])),
|
|
||||||
});
|
|
||||||
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
|
||||||
expect(readPhotoExif(exif)).toStrictEqual(heicFields);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The build's type check, not this test, makes sure `Photo` has a method
|
|
||||||
// for every `PhotoExif` field, whatever the fixtures hold: `Photo`
|
|
||||||
// implements a type with one method per field. This test checks that each
|
|
||||||
// method gives the field picked from the tags exif() returns.
|
|
||||||
it.each([
|
|
||||||
["JPEG", JPEG_WITH_EXIF],
|
|
||||||
["HEIC", HEIC_WITH_EXIF],
|
|
||||||
])(
|
|
||||||
"has a method for each field, agreeing with the tags exif() returns, for a %s",
|
|
||||||
async (_, bytes) => {
|
|
||||||
const lib = await open({ contentSource: stubSource(bytes) });
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
const fields = readPhotoExif(await photo.exif());
|
|
||||||
// The file holds every field, so every method is checked.
|
|
||||||
expect(fields).toStrictEqual(heicFields);
|
|
||||||
for (const [field, value] of Object.entries(fields)) {
|
|
||||||
expect(await photo[field as keyof PhotoExif]()).toStrictEqual(
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await lib.close();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("returns no EXIF tags for an original that is not an image", async () => {
|
|
||||||
const lib = await open();
|
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
||||||
expect(await photo.exif()).toStrictEqual({});
|
|
||||||
expect(await photo.dateTimeOriginal()).toBeUndefined();
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns no EXIF tags for a JPEG whose EXIF cannot be parsed", async () => {
|
|
||||||
const lib = await open({
|
|
||||||
contentSource: stubSource(JPEG_WITH_BAD_EXIF),
|
|
||||||
});
|
|
||||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws from exif() on a video without a content source, as the other content methods do", async () => {
|
|
||||||
const video = file(1, 1);
|
|
||||||
video.metadata.fileType = "video";
|
|
||||||
const lib = await open({
|
|
||||||
client: new FilesClient([video]),
|
|
||||||
contentSource: undefined,
|
|
||||||
});
|
|
||||||
await expect(lib.photos.byID({ fileID: 1 })!.exif()).rejects.toThrow(
|
|
||||||
/content cache/i,
|
|
||||||
);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns no EXIF tags for a video, without fetching it", async () => {
|
|
||||||
const video = file(1, 1);
|
|
||||||
video.metadata.fileType = "video";
|
|
||||||
const source = stubSource(JPEG_WITH_EXIF);
|
|
||||||
const lib = await open({
|
|
||||||
client: new FilesClient([video]),
|
|
||||||
contentSource: source,
|
|
||||||
});
|
|
||||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
|
|
||||||
expect(source.originalCalls()).toBe(0);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
* 1. **Fetch once, then serve from disk.** The first `original`/`thumbnail`
|
* 1. **Fetch once, then serve from disk.** The first `original`/`thumbnail`
|
||||||
* fetches through the request pool and stores the bytes; the next finds the
|
* fetches through the request pool and stores the bytes; the next finds the
|
||||||
* file present and returns its path with a single `skipped` event and no
|
* file present and returns its path with a single `skipped` event and no
|
||||||
* network. A file already stored at its save path under the
|
* network. A file already sitting in the backup `downloadDirectory` counts
|
||||||
* `downloadDirectory` counts as present too.
|
* as present too.
|
||||||
* 2. **Present-means-complete.** Content appears only by the streaming atomic
|
* 2. **Present-means-complete.** Content appears only by the streaming atomic
|
||||||
* writer's rename, so a file that exists is whole. The directory listing
|
* writer's rename, so a file that exists is whole. The directory listing
|
||||||
* taken at `open()` is the record of what is cached, and the orphan temp
|
* taken at `open()` is the record of what is cached, and the orphan temp
|
||||||
@@ -41,7 +41,6 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ContentCache,
|
ContentCache,
|
||||||
savePath,
|
|
||||||
type ContentSource,
|
type ContentSource,
|
||||||
type EnsureEvent,
|
type EnsureEvent,
|
||||||
} from "../../src/library/content.js";
|
} from "../../src/library/content.js";
|
||||||
@@ -153,48 +152,12 @@ const buildCache = (
|
|||||||
pools: args.pools ?? new RequestPools(),
|
pools: args.pools ?? new RequestPools(),
|
||||||
source,
|
source,
|
||||||
cacheDirectory: cacheDir,
|
cacheDirectory: cacheDir,
|
||||||
downloadDirectory: args.downloadDirectory ?? join(root, "photos"),
|
downloadDirectory: args.downloadDirectory,
|
||||||
getFile: (id) => byID.get(id),
|
getFile: (id) => byID.get(id),
|
||||||
});
|
});
|
||||||
return { cache, source };
|
return { cache, source };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Microseconds, as Ente stores times, for noon local time on a day, so the
|
|
||||||
// machine's time zone cannot move the photo to another day.
|
|
||||||
const noon = (year: number, month: number, day: number): number =>
|
|
||||||
new Date(year, month - 1, day, 12).getTime() * 1000;
|
|
||||||
|
|
||||||
describe("savePath", () => {
|
|
||||||
it("files an original by year, month and day under the root", () => {
|
|
||||||
const f = file(12345, "IMG_0001.HEIC");
|
|
||||||
f.metadata.creationTime = noon(2026, 3, 1);
|
|
||||||
|
|
||||||
expect(savePath("/photos", f)).toBe(
|
|
||||||
"/photos/2026/2026-03/2026-03-01/2026-03-01.12345.HEIC",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dates an original by the date the user set, when there is one", () => {
|
|
||||||
const f = file(7, "a.jpg");
|
|
||||||
f.metadata.creationTime = noon(2026, 3, 1);
|
|
||||||
f.pubMagicMetadata = { editedTime: noon(1999, 12, 31) };
|
|
||||||
|
|
||||||
expect(savePath("/photos", f)).toBe(
|
|
||||||
"/photos/1999/1999-12/1999-12-31/1999-12-31.7.jpg",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("takes the extension from the title it was uploaded with, not a new name", () => {
|
|
||||||
const f = file(8, "upload");
|
|
||||||
f.metadata.creationTime = noon(2026, 3, 1);
|
|
||||||
f.pubMagicMetadata = { editedName: "renamed.png" };
|
|
||||||
|
|
||||||
expect(savePath("/photos", f)).toBe(
|
|
||||||
"/photos/2026/2026-03/2026-03-01/2026-03-01.8.bin",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ContentCache.open", () => {
|
describe("ContentCache.open", () => {
|
||||||
it("creates the cache directories with 0700 permissions", async () => {
|
it("creates the cache directories with 0700 permissions", async () => {
|
||||||
const { cache } = buildCache();
|
const { cache } = buildCache();
|
||||||
@@ -311,17 +274,11 @@ describe("ContentCache.original / thumbnail", () => {
|
|||||||
|
|
||||||
it("serves a file already present in the download directory without fetching", async () => {
|
it("serves a file already present in the download directory without fetching", async () => {
|
||||||
const downloadDirectory = join(root, "backup");
|
const downloadDirectory = join(root, "backup");
|
||||||
const day = join(downloadDirectory, "2026", "2026-03", "2026-03-01");
|
mkdirSync(join(downloadDirectory, "originals"), { recursive: true });
|
||||||
mkdirSync(day, { recursive: true });
|
const backupPath = join(downloadDirectory, "originals", "1.jpg");
|
||||||
const backupPath = join(day, "2026-03-01.1.jpg");
|
|
||||||
writeFileSync(backupPath, "from-backup");
|
writeFileSync(backupPath, "from-backup");
|
||||||
const f = file(1);
|
|
||||||
f.metadata.creationTime = noon(2026, 3, 1);
|
|
||||||
|
|
||||||
const { cache, source } = buildCache({
|
const { cache, source } = buildCache({ downloadDirectory });
|
||||||
downloadDirectory,
|
|
||||||
files: [f],
|
|
||||||
});
|
|
||||||
await cache.open();
|
await cache.open();
|
||||||
|
|
||||||
const events: EnsureEvent["status"][] = [];
|
const events: EnsureEvent["status"][] = [];
|
||||||
@@ -566,6 +523,43 @@ describe("ContentCache live photos", () => {
|
|||||||
expect(events).toEqual(["skipped"]);
|
expect(events).toEqual(["skipped"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces a live photo an earlier version stored as a ZIP under the image's name", async () => {
|
||||||
|
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
||||||
|
mkdirSync(originals(), { recursive: true });
|
||||||
|
writeFileSync(join(originals(), "5.HEIC"), livePhotoZip());
|
||||||
|
const cache = cacheOf([live], new Map([[5, body]]));
|
||||||
|
// Opened without being told that file 5 is a live photo, the cache
|
||||||
|
// records the ZIP, and does not serve it.
|
||||||
|
await cache.open();
|
||||||
|
|
||||||
|
const result = await cache.original(5);
|
||||||
|
|
||||||
|
expect(result.videoPath).toBe(join(originals(), "5.mov"));
|
||||||
|
expect(readdirSync(originals()).sort()).toEqual([
|
||||||
|
"5.heic",
|
||||||
|
"5.livephoto.json",
|
||||||
|
"5.mov",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a live photo's ZIP an earlier version stored when it opens, so the precache fetches the image and video", async () => {
|
||||||
|
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
||||||
|
mkdirSync(originals(), { recursive: true });
|
||||||
|
writeFileSync(join(originals(), "5.HEIC"), livePhotoZip());
|
||||||
|
const cache = cacheOf([live], new Map([[5, body]]));
|
||||||
|
|
||||||
|
await cache.open((fileID) => fileID === 5);
|
||||||
|
|
||||||
|
expect(readdirSync(originals())).toEqual([]);
|
||||||
|
expect(cache.pathsFor(5)).toEqual({});
|
||||||
|
const [fetched] = await cache.ensureOriginals({ fileIDs: [5] });
|
||||||
|
expect(fetched).toEqual({
|
||||||
|
fileID: 5,
|
||||||
|
path: join(originals(), "5.heic"),
|
||||||
|
});
|
||||||
|
expect(cache.pathsFor(5)).toEqual({ originalPath: fetched!.path });
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves the image and video another process has just stored when it opens before their JSON file is written", async () => {
|
it("leaves the image and video another process has just stored when it opens before their JSON file is written", async () => {
|
||||||
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
||||||
const server = cdnSource(new Map([[5, body]]));
|
const server = cdnSource(new Map([[5, body]]));
|
||||||
@@ -596,36 +590,6 @@ describe("ContentCache live photos", () => {
|
|||||||
expect(second!.pathsFor(5)).toEqual({});
|
expect(second!.pathsFor(5)).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches a live photo's image and video again when the cache opened before knowing it is a live photo and no JSON file names them", async () => {
|
|
||||||
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
|
||||||
mkdirSync(originals(), { recursive: true });
|
|
||||||
writeFileSync(join(originals(), "5.heic"), "an image");
|
|
||||||
writeFileSync(join(originals(), "5.mov"), "a video");
|
|
||||||
const cache = cacheOf([live], new Map([[5, body]]));
|
|
||||||
// Opened without being told that file 5 is a live photo, the cache
|
|
||||||
// records one of the two files as its original, with no video.
|
|
||||||
await cache.open();
|
|
||||||
|
|
||||||
const events: string[] = [];
|
|
||||||
const result = await cache.original(5, {
|
|
||||||
onProgress: (e) => events.push(e.status),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(events.at(-1)).toBe("done");
|
|
||||||
expect(result).toEqual({
|
|
||||||
path: join(originals(), "5.heic"),
|
|
||||||
videoPath: join(originals(), "5.mov"),
|
|
||||||
bytes: IMAGE.length,
|
|
||||||
});
|
|
||||||
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
|
|
||||||
expect(readFileSync(result.videoPath!)).toEqual(Buffer.from(VIDEO));
|
|
||||||
expect(
|
|
||||||
JSON.parse(
|
|
||||||
readFileSync(join(originals(), "5.livephoto.json"), "utf-8"),
|
|
||||||
),
|
|
||||||
).toEqual({ image: "5.heic", video: "5.mov" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(["missing", "empty"])(
|
it.each(["missing", "empty"])(
|
||||||
"fetches a live photo again when the video its JSON file names is %s",
|
"fetches a live photo again when the video its JSON file names is %s",
|
||||||
async (state) => {
|
async (state) => {
|
||||||
@@ -685,7 +649,6 @@ describe("ContentCache live photos", () => {
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
cacheDirectory: cacheDir,
|
cacheDirectory: cacheDir,
|
||||||
downloadDirectory: join(root, "photos"),
|
|
||||||
getFile: (id) => [a.file, b.file].find((f) => f.id === id),
|
getFile: (id) => [a.file, b.file].find((f) => f.id === id),
|
||||||
// Room for one live photo, on a disk with plenty free.
|
// Room for one live photo, on a disk with plenty free.
|
||||||
cacheOriginalsMaxBytes: size,
|
cacheOriginalsMaxBytes: size,
|
||||||
|
|||||||
@@ -280,7 +280,6 @@ describe("Precache eviction integration", () => {
|
|||||||
pools: new RequestPools(),
|
pools: new RequestPools(),
|
||||||
source,
|
source,
|
||||||
cacheDirectory: cacheDir,
|
cacheDirectory: cacheDir,
|
||||||
downloadDirectory: join(root, "photos"),
|
|
||||||
getFile: (id) => byID.get(id),
|
getFile: (id) => byID.get(id),
|
||||||
statfs,
|
statfs,
|
||||||
cacheOriginalsMaxBytes: 25, // holds two 10-byte originals
|
cacheOriginalsMaxBytes: 25, // holds two 10-byte originals
|
||||||
@@ -349,7 +348,6 @@ describe("Precache preemption", () => {
|
|||||||
pools: new RequestPools({ contentConcurrency: 1 }),
|
pools: new RequestPools({ contentConcurrency: 1 }),
|
||||||
source,
|
source,
|
||||||
cacheDirectory: join(root, "cache"),
|
cacheDirectory: join(root, "cache"),
|
||||||
downloadDirectory: join(root, "photos"),
|
|
||||||
getFile: (id) => byID.get(id),
|
getFile: (id) => byID.get(id),
|
||||||
statfs: async () => ({ bsize: 1, bavail: 1_000_000_000 }),
|
statfs: async () => ({ bsize: 1, bavail: 1_000_000_000 }),
|
||||||
freeBelowBytes: 0,
|
freeBelowBytes: 0,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
makeAlbumsAPI,
|
makeAlbumsAPI,
|
||||||
makePhotosAPI,
|
makePhotosAPI,
|
||||||
makeTimelineAPI,
|
makeTimelineAPI,
|
||||||
type SavePathLookup,
|
|
||||||
type TimelineGroup,
|
type TimelineGroup,
|
||||||
} from "../../src/library/read.js";
|
} from "../../src/library/read.js";
|
||||||
import { Library } from "../../src/library/index.js";
|
import { Library } from "../../src/library/index.js";
|
||||||
@@ -102,19 +101,12 @@ const file = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build the three API objects over one fixed projection, the way `Library`
|
// Build the three API objects over one fixed projection, the way `Library`
|
||||||
// wires them over its live store. Save paths are covered in
|
// wires them over its live store.
|
||||||
// content-library.test.ts; these tests never ask for one.
|
|
||||||
const apis = (records: DerivedRecords) => {
|
const apis = (records: DerivedRecords) => {
|
||||||
const derive = () => records;
|
const derive = () => records;
|
||||||
const saves: SavePathLookup = {
|
|
||||||
savePath: () => {
|
|
||||||
throw new Error("no save paths in these tests");
|
|
||||||
},
|
|
||||||
isLocal: () => false,
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
albums: makeAlbumsAPI(derive, saves),
|
albums: makeAlbumsAPI(derive),
|
||||||
photos: makePhotosAPI(derive, saves),
|
photos: makePhotosAPI(derive),
|
||||||
timeline: makeTimelineAPI(derive),
|
timeline: makeTimelineAPI(derive),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -217,29 +209,6 @@ describe("lib.photos", () => {
|
|||||||
expect("key" in photo.record()).toBe(false);
|
expect("key" in photo.record()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("byID exposes modifiedAt, hash and the year taken", () => {
|
|
||||||
// Mid-July, so the year is 2021 in every time zone.
|
|
||||||
const takenAt = Date.UTC(2021, 6, 15, 12);
|
|
||||||
const records = deriveRecords(
|
|
||||||
[collection(1)],
|
|
||||||
[
|
|
||||||
file(1001, 1, {
|
|
||||||
metadata: {
|
|
||||||
title: "IMG.jpg",
|
|
||||||
fileType: "image",
|
|
||||||
creationTime: micros(takenAt),
|
|
||||||
modificationTime: micros(1_700_000_123_456),
|
|
||||||
hash: "aGFzaA==",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const photo = apis(records).photos.byID({ fileID: 1001 })!;
|
|
||||||
expect(photo.modifiedAt).toBe(ms(1_700_000_123_456));
|
|
||||||
expect(photo.hash).toBe("aGFzaA==");
|
|
||||||
expect(photo.year).toBe(2021);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("byID returns undefined for an unknown file id", () => {
|
it("byID returns undefined for an unknown file id", () => {
|
||||||
const records = deriveRecords([collection(1)], [file(1, 1)]);
|
const records = deriveRecords([collection(1)], [file(1, 1)]);
|
||||||
expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined();
|
expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined();
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ describe("deriveRecords: photo mapping", () => {
|
|||||||
expect("width" in rec).toBe(false);
|
expect("width" in rec).toBe(false);
|
||||||
expect("height" in rec).toBe(false);
|
expect("height" in rec).toBe(false);
|
||||||
expect("latitude" in rec).toBe(false);
|
expect("latitude" in rec).toBe(false);
|
||||||
expect("hash" in rec).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads archived and hidden from private magicMetadata.visibility", () => {
|
it("reads archived and hidden from private magicMetadata.visibility", () => {
|
||||||
|
|||||||
+1
-1
@@ -18,5 +18,5 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "bin/**/*", "examples/**/*"]
|
"include": ["src/**/*", "bin/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -702,11 +702,6 @@
|
|||||||
loupe "^3.1.2"
|
loupe "^3.1.2"
|
||||||
tinyrainbow "^1.2.0"
|
tinyrainbow "^1.2.0"
|
||||||
|
|
||||||
"@xmldom/xmldom@^0.9.10":
|
|
||||||
version "0.9.12"
|
|
||||||
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.12.tgz#1f84c07cb95ccf28202299f77b5fd7fc257151e8"
|
|
||||||
integrity sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==
|
|
||||||
|
|
||||||
acorn-jsx@^5.3.2:
|
acorn-jsx@^5.3.2:
|
||||||
version "5.3.2"
|
version "5.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||||
@@ -1007,12 +1002,10 @@ esutils@^2.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
||||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||||
|
|
||||||
exifreader@4.46.0:
|
exif-reader@2.0.3:
|
||||||
version "4.46.0"
|
version "2.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/exifreader/-/exifreader-4.46.0.tgz#b6216eae512997587c45114f972cc14ca979205f"
|
resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d"
|
||||||
integrity sha512-ksHTpjXKWzbckY+bYlGaomG0EobHJkaMWLg5OPbzlTPda04F2dfrDfYSz8iAPp/kXYUSQdINBVI6nCAjQ8PQ/Q==
|
integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==
|
||||||
optionalDependencies:
|
|
||||||
"@xmldom/xmldom" "^0.9.10"
|
|
||||||
|
|
||||||
expect-type@^1.1.0:
|
expect-type@^1.1.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user