Compare commits
3
Commits
129c648680
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
982b878d69 | ||
|
|
7ec71bf7ec | ||
|
|
8583863ec0 |
@@ -159,10 +159,11 @@ After a `1`, the next run fetches each missing original again, fetches the ML
|
|||||||
data that is not cached, and rebuilds the album links. An album quak could not
|
data that is not cached, and rebuilds the album links. An album quak could not
|
||||||
decrypt, or a file in `failures.json` because quak could not decrypt it, keeps
|
decrypt, or a file in `failures.json` because quak could not decrypt it, keeps
|
||||||
the exit code at `1` until the server sends a copy that decrypts, or deletes it
|
the exit code at `1` until the server sends a copy that decrypts, or deletes it
|
||||||
(see "Backup layout"). An original that a `--verify` run could not read, or put
|
(see "Backup layout"). An original that a `--verify` run could not read, or
|
||||||
back with bytes that still do not match, stays at its save path. Only a later
|
could not replace with a download that matches, stays at its save path as it
|
||||||
`--verify` run checks it again: a run without `--verify` leaves it as it is and
|
was. Only a later `--verify` run checks it again: a run without `--verify`
|
||||||
takes the file out of `failures.json`, so that run can exit 0.
|
leaves it as it is and takes the file out of `failures.json`, so that run can
|
||||||
|
exit 0.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@@ -660,7 +661,15 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`,
|
|||||||
`backup-metadata`, `helper list-missing-thumbnails` and
|
`backup-metadata`, `helper list-missing-thumbnails` and
|
||||||
`helper fix-missing-thumbnails` — force a fresh server round-trip before they
|
`helper fix-missing-thumbnails` — force a fresh server round-trip before they
|
||||||
answer, so they report current account state rather than whatever the cache last
|
answer, so they report current account state rather than whatever the cache last
|
||||||
held. If that round-trip fails, the command prints the error on one line and
|
held. `collections` asks the server for the collection list alone, in one
|
||||||
|
request, and neither fetches any collection's files nor changes the cache. The
|
||||||
|
others wait for a refresh. With an empty cache, as on a first run, that refresh
|
||||||
|
fetches the files of every collection, one collection after another, and stores
|
||||||
|
nothing until it has them all, which on a large account takes a while. Meanwhile
|
||||||
|
the command prints a line on stderr for each collection whose files it has
|
||||||
|
fetched, such as `Fetched the files of 3 of 120 collections`; with `--json`,
|
||||||
|
`files` and the two helpers print no such line. stdout holds only the command's
|
||||||
|
output. If that round-trip fails, the command prints the error on one line and
|
||||||
exits 1, or 3 when the server no longer accepts the saved session (see "Session
|
exits 1, or 3 when the server no longer accepts the saved session (see "Session
|
||||||
handling"). An album or file quak cannot decrypt does not fail it; the command
|
handling"). An album or file quak cannot decrypt does not fail it; the command
|
||||||
answers from what did decrypt (see "Default reads vs. fresh reads").
|
answers from what did decrypt (see "Default reads vs. fresh reads").
|
||||||
@@ -692,13 +701,15 @@ reads no tag from is recorded, base64, as `exifRaw`, with the reason in
|
|||||||
and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
|
and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
|
||||||
|
|
||||||
`backup --verify` also hashes the originals already in the backup and replaces
|
`backup --verify` also hashes the originals already in the backup and replaces
|
||||||
any that do not match the content hash Ente records; one it cannot replace goes
|
any that do not match the content hash Ente records; one it cannot replace stays
|
||||||
into `failures.json` (see "Backup layout").
|
as it was and goes into `failures.json` (see "Backup layout").
|
||||||
|
|
||||||
`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
|
fails, the error is logged, each of its files is written with the reason in an
|
||||||
`mlDataError` field instead of `mlData`, and the dump goes on. The exit code is
|
`mlDataError` field instead of `mlData`, and the dump goes on. A file whose ML
|
||||||
non-zero if any ML data request failed.
|
data does not decrypt, gunzip or parse is logged and written the same way, with
|
||||||
|
why in `mlDataError`. The exit code is non-zero if any file was written with
|
||||||
|
`mlDataError`.
|
||||||
|
|
||||||
`helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only,
|
`helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only,
|
||||||
because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image
|
because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image
|
||||||
@@ -763,8 +774,15 @@ A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as
|
|||||||
for has no `mlData`. The backup waits for the library's ML data fetch to finish
|
for has no `mlData`. The backup waits for the library's ML data fetch to finish
|
||||||
before it writes the JSON files. If that fetch fails, each file whose ML data is
|
before it writes the JSON files. If that fetch fails, each file whose ML data is
|
||||||
not in the cache gets the reason in `mlDataError` instead and counts as failed,
|
not in the cache gets the reason in `mlDataError` instead and counts as failed,
|
||||||
and the next run fetches it again. The JSON files are rewritten on every run, so
|
and the next run fetches it again. The same goes for a file with no ML data in
|
||||||
ML data that arrived since the last run appears.
|
the cache whose ML data the server sent but quak could not decrypt, gunzip or
|
||||||
|
parse. Its `mlDataError` says why, and `failures.json` records it with an error
|
||||||
|
starting `ML data: `. quak keeps that reason in memory only and caches nothing
|
||||||
|
for the file, so the next run asks for its ML data again. An ML data fetch that
|
||||||
|
fails, such as one the library's refresh starts while the backup writes the JSON
|
||||||
|
files, leaves the reasons as the last fetch that succeeded left them. The JSON
|
||||||
|
files are rewritten on every run, so ML data that arrived since the last run
|
||||||
|
appears.
|
||||||
|
|
||||||
A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records
|
A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records
|
||||||
from its original, or from a live photo's image: `format`, `width` and `height`
|
from its original, or from a live photo's image: `format`, `width` and `height`
|
||||||
@@ -878,33 +896,41 @@ original already at its save path the way a download is checked (see "On-disk
|
|||||||
cache layout" below): its bytes, read in chunks, or a live photo's image and
|
cache layout" below): its bytes, read in chunks, or a live photo's image and
|
||||||
video, joined as `<imageHash>:<videoHash>`. An original that matches the content
|
video, joined as `<imageHash>:<videoHash>`. An original that matches the content
|
||||||
hash its metadata records is left as it is. One that does not is logged on one
|
hash its metadata records is left as it is. One that does not is logged on one
|
||||||
line naming the file, deleted (a live photo's image and video both), and put
|
line naming the file, and replaced once a matching copy is downloaded, in the
|
||||||
back in the same run like a missing one: downloaded, or copied from the cache if
|
same run. The copy is downloaded even when the cache holds the original, because
|
||||||
the cache holds it. What is put back is hashed too, because a copy from the
|
a copy from the cache is not checked as a download is. It is written to a
|
||||||
cache is not checked as a download is. If it still does not match, it stays at
|
temporary file beside the original, a live photo's image and video each to its
|
||||||
its save path and the file goes into `failures.json`, as it does when the
|
own, hashed the same way, and renamed over the original only when it matches; a
|
||||||
download fails. A file whose metadata records no hash is left as it is and
|
live photo's image and video are both hashed before either is renamed. Until
|
||||||
counted as unchecked. A stored original that cannot be read is left as it is and
|
then the original stays as it was: when the download fails, or what it brings
|
||||||
counts as failed. The summary and `--json` add the counts `verified`,
|
does not match either, the temporary files are deleted, the original keeps its
|
||||||
`mismatched` and `unchecked`, all of originals that were already stored; one
|
bytes and its album links, and the file goes into `failures.json`. A run that
|
||||||
first downloaded in this run is in none of them. A mismatch that was put back
|
stops between renaming a live photo's new image and its new video leaves the new
|
||||||
with matching bytes does not make the exit code non-zero. Without `--verify`
|
image beside the old video, which a later `--verify` run replaces if the video
|
||||||
nothing is hashed, the summary is unchanged, and the three counts are 0 in
|
was the part that did not match. A file whose metadata records no hash is left
|
||||||
`--json`.
|
as it is and counted as unchecked. A stored original that cannot be read is left
|
||||||
|
as it is and counts as failed. The summary and `--json` add the counts
|
||||||
|
`verified`, `mismatched` and `unchecked`, all of originals that were already
|
||||||
|
stored; one first downloaded in this run is in none of them. A mismatch that was
|
||||||
|
replaced with matching bytes does not make the exit code non-zero. Without
|
||||||
|
`--verify` nothing is hashed, the summary is unchanged, and the three counts are
|
||||||
|
0 in `--json`.
|
||||||
|
|
||||||
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-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A copy that
|
||||||
killed can leave one of these temporary files behind; the next backup deletes
|
`--verify` downloads to replace an original is written to such a
|
||||||
those whose process is no longer running. The content cache uses the same
|
`.quak-<pid>-<random>.tmp` too, a live photo's image and video to
|
||||||
scheme, and opening a library deletes the temporary files in the cache whose
|
`.quak-<pid>-<random>.<ext>`, each with its own extension. A run that is killed
|
||||||
process is no longer running, so a download another process has in progress in
|
can leave one of these temporary files behind; the next backup deletes those
|
||||||
the same cache is left alone. The rename replaces whatever was at the
|
whose process is no longer running. The content cache uses the same scheme, and
|
||||||
destination rather than writing through it: a symlink there is replaced, not
|
opening a library deletes the temporary files in the cache whose process is no
|
||||||
followed, and the new file has the temporary file's permissions, not those of
|
longer running, so a download another process has in progress in the same cache
|
||||||
the file it replaced.
|
is left alone. The rename replaces whatever was at the destination rather than
|
||||||
|
writing through it: a symlink there is replaced, not followed, and the new file
|
||||||
|
has the temporary file's permissions, not those of the file it replaced.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
@@ -943,7 +969,11 @@ loop, and resolves to a `Library`. On an empty cache it awaits the first
|
|||||||
refresh, so it opens onto the account's data whenever the server is reachable;
|
refresh, so it opens onto the account's data whenever the server is reachable;
|
||||||
if that refresh fails, it opens with no data and records the error in
|
if that refresh fails, it opens with no data and records the error in
|
||||||
`lib.status()`. On an existing cache it returns immediately and refreshes in the
|
`lib.status()`. On an existing cache it returns immediately and refreshes in the
|
||||||
background, so an unreachable server does not block opening.
|
background, so an unreachable server does not block opening. With
|
||||||
|
`refreshOnOpen: false` it starts no refresh and returns at once onto what the
|
||||||
|
cache holds, empty or not; a refresh then starts only when `fresh()` or
|
||||||
|
`lib.backup()` asks, or the background timer fires. The CLI's read commands open
|
||||||
|
it so (see "CLI surface").
|
||||||
|
|
||||||
`LibraryOptions`:
|
`LibraryOptions`:
|
||||||
|
|
||||||
@@ -953,6 +983,7 @@ background, so an unreachable server does not block opening.
|
|||||||
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
|
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
|
||||||
| `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached |
|
| `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached |
|
||||||
| `refreshIntervalSeconds` | `3` | background refresh cadence; `Infinity` for no background timer |
|
| `refreshIntervalSeconds` | `3` | background refresh cadence; `Infinity` for no background timer |
|
||||||
|
| `refreshOnOpen` | `true` | start a refresh as the library opens |
|
||||||
| `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 |
|
||||||
@@ -966,6 +997,14 @@ background, so an unreachable server does not block opening.
|
|||||||
The default `downloadDirectory` is resolved against the working directory once,
|
The default `downloadDirectory` is resolved against the working directory once,
|
||||||
when the library opens; `lib.downloadDirectory` holds the result.
|
when the library opens; `lib.downloadDirectory` holds the result.
|
||||||
|
|
||||||
|
`onProgress` receives a `RefreshEvent` with status `"started"` as a refresh, an
|
||||||
|
ML data fetch or a precache sweep starts, and `"done"` or `"failed"` as it ends.
|
||||||
|
In between, a refresh sends `"progress"` after each collection whose files it
|
||||||
|
fetched, with `fetched`, how many collections it has fetched the files of so
|
||||||
|
far, and `total`, how many it will. A refresh fetches the files of each
|
||||||
|
collection that is new or changed since the cache's copy, so on an empty cache
|
||||||
|
it fetches those of every collection.
|
||||||
|
|
||||||
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).
|
||||||
@@ -978,6 +1017,14 @@ resolves once that refresh (including its cache write), the ML data fetch and
|
|||||||
the precache fetches already running have all finished, so the cache directory
|
the precache fetches already running have all finished, so the cache directory
|
||||||
can then be removed.
|
can then be removed.
|
||||||
|
|
||||||
|
`lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML
|
||||||
|
data the last ML data fetch that succeeded got but could not decrypt, gunzip or
|
||||||
|
parse; the `fetchMLData` event with status `"done"` carries the same list. A
|
||||||
|
fetch that is still running leaves the list as it is until it ends, and a fetch
|
||||||
|
that fails leaves it as it was. Such ML data is not cached, so the next ML data
|
||||||
|
fetch asks for it again. The reasons are kept in memory only, never in the
|
||||||
|
cache.
|
||||||
|
|
||||||
### 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
|
||||||
@@ -997,6 +1044,15 @@ rejects the caller (default reads stay silent and keep serving the last good
|
|||||||
copy). The CLI's read commands use fresh reads (issue
|
copy). The CLI's read commands use fresh reads (issue
|
||||||
https://git.eeqj.de/sneak/quak/issues/75).
|
https://git.eeqj.de/sneak/quak/issues/75).
|
||||||
|
|
||||||
|
`await lib.freshCollections()` is the fresh read of the collection list alone:
|
||||||
|
one collections request, answered with every live collection that decrypts, as
|
||||||
|
the `Collection` objects `listCollections()` returns, in the server's order. It
|
||||||
|
fetches no collection's files and stores nothing, not even the list: a refresh
|
||||||
|
fetches a collection's files only when the collection is new or has changed
|
||||||
|
since the stored copy, so a collection stored without its files would never get
|
||||||
|
them. `quak collections` uses it (issue
|
||||||
|
https://git.eeqj.de/sneak/quak/issues/210).
|
||||||
|
|
||||||
An album or file in the server's answer that quak cannot decrypt, or whose
|
An album or file in the server's answer that quak cannot decrypt, or whose
|
||||||
metadata is not JSON, does not fail the refresh. The refresh leaves it out, an
|
metadata is not JSON, does not fail the refresh. The refresh leaves it out, an
|
||||||
album with all its files, and drops any older copy the cache held, so reads
|
album with all its files, and drops any older copy the cache held, so reads
|
||||||
|
|||||||
@@ -17,17 +17,49 @@ pre-1.0
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Three bugs are open: https://git.eeqj.de/sneak/quak/issues/198,
|
One bug is open: https://git.eeqj.de/sneak/quak/issues/205. The check of quak
|
||||||
https://git.eeqj.de/sneak/quak/issues/199 and
|
against a live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an
|
||||||
https://git.eeqj.de/sneak/quak/issues/205. The check of quak against a live Ente
|
account only sneak has, so it waits on him.
|
||||||
account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak
|
|
||||||
has, so it waits on him.
|
|
||||||
|
|
||||||
Tagging and releases are decided by sneak alone, and happen only when he
|
Tagging and releases are decided by sneak alone, and happen only when he
|
||||||
declares one.
|
declares one.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-18: `quak collections` no longer waits for the files of every
|
||||||
|
collection (issue 210). On an empty cache it opened its library with a refresh
|
||||||
|
that fetched and decrypted the files of each collection in turn, printing
|
||||||
|
nothing and storing nothing until all were done, so on a large account it
|
||||||
|
seemed to hang. It now reads the collection list alone through the new
|
||||||
|
`lib.freshCollections()`, one request that stores nothing. The read commands
|
||||||
|
open their library with the new `refreshOnOpen: false`, so each makes only the
|
||||||
|
refresh it asks for. A refresh sends a `"progress"` event after each
|
||||||
|
collection whose files it fetched, and the commands that wait on one print it
|
||||||
|
on stderr, except with `--json`.
|
||||||
|
|
||||||
|
- 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is
|
||||||
|
no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for
|
||||||
|
each such file beside the payloads; ML data that parses to something other
|
||||||
|
than a JSON object, such as `null`, is one of them. The library keeps the
|
||||||
|
reasons from its last ML data fetch that succeeded in memory, not in the
|
||||||
|
cache, and gives them in `status().mlDataErrors` and in the `fetchMLData`
|
||||||
|
event with status `"done"`; a fetch still running or one that fails does not
|
||||||
|
change them. `quak backup` writes the reason as `mlDataError` in the file's
|
||||||
|
JSON when no ML data is cached for it, and records the file in `failures.json`
|
||||||
|
with an error starting `ML data: `; `backup-metadata` writes `mlDataError`
|
||||||
|
too. Both exit 1. Tests serve ML data encrypted under a key that is not the
|
||||||
|
file's.
|
||||||
|
|
||||||
|
- 2026-10-16: With `--verify`, an original whose hash does not match is no
|
||||||
|
longer removed before its replacement is downloaded (issue 199).
|
||||||
|
`replaceOriginal` in `src/library/content.ts` downloads the replacement to a
|
||||||
|
temporary file beside it, a live photo's image and video each to its own,
|
||||||
|
hashes it and renames it over the original only when it matches. When the
|
||||||
|
download fails or does not match either, the original keeps its bytes and
|
||||||
|
links, and the file goes into `failures.json`. The replacement is always
|
||||||
|
downloaded, through the new `fetchOriginal` of the content cache, never copied
|
||||||
|
from the cache.
|
||||||
|
|
||||||
- 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused
|
- 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused
|
||||||
connection and a name that does not resolve, and the backup records either as
|
connection and a name that does not resolve, and the backup records either as
|
||||||
transient (issue 206). Bun's `fetch` reports them with codes of its own,
|
transient (issue 206). Bun's `fetch` reports them with codes of its own,
|
||||||
|
|||||||
+54
-66
@@ -41,8 +41,9 @@
|
|||||||
//
|
//
|
||||||
// With `verify`, each original already at its save path is hashed as the
|
// With `verify`, each original already at its save path is hashed as the
|
||||||
// download check hashes it, and one that does not match the content hash its
|
// download check hashes it, and one that does not match the content hash its
|
||||||
// metadata records is removed and fetched again in the same run. What is put
|
// metadata records is replaced once a matching copy is downloaded, in the same
|
||||||
// back is hashed too, and recorded as failed if it still does not match.
|
// run. Until then it stays as it is: a download that fails, or that does not
|
||||||
|
// match either, leaves it in place and is recorded as failed.
|
||||||
//
|
//
|
||||||
// Resilience (issue #8): no per-file condition aborts the run. A failed
|
// Resilience (issue #8): no per-file condition aborts the run. A failed
|
||||||
// download, a failed symlink, or ML data missing because the ML data fetch
|
// download, a failed symlink, or ML data missing because the ML data fetch
|
||||||
@@ -61,7 +62,6 @@
|
|||||||
// code.
|
// code.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createReadStream,
|
|
||||||
lstatSync,
|
lstatSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
@@ -77,12 +77,6 @@ import { readFile } from "node:fs/promises";
|
|||||||
import { dirname, extname, join, relative, resolve } from "node:path";
|
import { dirname, extname, join, relative, resolve } from "node:path";
|
||||||
import lockfile from "proper-lockfile";
|
import lockfile from "proper-lockfile";
|
||||||
|
|
||||||
import {
|
|
||||||
chunkHashFinal,
|
|
||||||
chunkHashInit,
|
|
||||||
chunkHashUpdate,
|
|
||||||
init,
|
|
||||||
} from "./crypto/index.js";
|
|
||||||
import { removeLeftoverTempFiles } from "./download/index.js";
|
import { removeLeftoverTempFiles } from "./download/index.js";
|
||||||
import { describeError } from "./errors.js";
|
import { describeError } from "./errors.js";
|
||||||
import {
|
import {
|
||||||
@@ -94,8 +88,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
copyAtomic,
|
copyAtomic,
|
||||||
placeOriginal,
|
placeOriginal,
|
||||||
|
replaceOriginal,
|
||||||
savePath,
|
savePath,
|
||||||
storedAtSavePath,
|
storedAtSavePath,
|
||||||
|
storedHash,
|
||||||
} from "./library/content.js";
|
} from "./library/content.js";
|
||||||
import { representative } from "./library/records.js";
|
import { representative } from "./library/records.js";
|
||||||
import type { Undecryptable } from "./library/store.js";
|
import type { Undecryptable } from "./library/store.js";
|
||||||
@@ -152,9 +148,9 @@ export interface BackupResult {
|
|||||||
// Originals already at their save path and left untouched.
|
// Originals already at their save path and left untouched.
|
||||||
skipped: number;
|
skipped: number;
|
||||||
// With `verify`, the originals already at their save path whose hash
|
// With `verify`, the originals already at their save path whose hash
|
||||||
// matched, those whose hash did not (each removed and fetched again), and
|
// matched, those whose hash did not (each replaced once a matching copy is
|
||||||
// those whose metadata records no hash (left as they are). All three are
|
// downloaded), and those whose metadata records no hash (left as they
|
||||||
// zero without `verify`.
|
// are). All three are zero without `verify`.
|
||||||
verified: number;
|
verified: number;
|
||||||
mismatched: number;
|
mismatched: number;
|
||||||
unchecked: number;
|
unchecked: number;
|
||||||
@@ -192,6 +188,15 @@ export interface BackupLibrary {
|
|||||||
fileID: number,
|
fileID: number,
|
||||||
destination: string,
|
destination: string,
|
||||||
): Promise<{ path: string; videoPath?: string }>;
|
): Promise<{ path: string; videoPath?: string }>;
|
||||||
|
// Fetch an original's bytes to `destination` through the content pool,
|
||||||
|
// a live photo as its image and its video beside it, even when the cache
|
||||||
|
// or the library's save path already holds it, and return where they
|
||||||
|
// landed. With `verify`, an original whose hash does not match is replaced
|
||||||
|
// with what this fetches.
|
||||||
|
fetchOriginal(
|
||||||
|
fileID: number,
|
||||||
|
destination: string,
|
||||||
|
): Promise<{ path: string; videoPath?: string }>;
|
||||||
thumbnail(fileID: number): Promise<{ path: string }>;
|
thumbnail(fileID: number): Promise<{ path: string }>;
|
||||||
// How many originals the backup works on at once: the limit of the
|
// How many originals the backup works on at once: the limit of the
|
||||||
// library's content pool.
|
// library's content pool.
|
||||||
@@ -203,6 +208,11 @@ export interface BackupLibrary {
|
|||||||
// A file's cached ML data, as `lib.mldata.forFile()` returns it, or
|
// A file's cached ML data, as `lib.mldata.forFile()` returns it, or
|
||||||
// undefined when none is cached.
|
// undefined when none is cached.
|
||||||
mlData(fileID: number): Promise<MLData | undefined>;
|
mlData(fileID: number): Promise<MLData | undefined>;
|
||||||
|
// Why the ML data the last ML data fetch that succeeded got for a file did
|
||||||
|
// not decrypt, gunzip or parse, or undefined when it did or the fetch got
|
||||||
|
// none. A fetch still running does not change it. A fetch that fails
|
||||||
|
// leaves it as it was.
|
||||||
|
mlDataError(fileID: number): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FailureClass = "transient" | "permanent" | "unknown";
|
type FailureClass = "transient" | "permanent" | "unknown";
|
||||||
@@ -444,26 +454,6 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The content hash of the original stored at `stored`, computed as the download
|
|
||||||
// check computes it: over each file's bytes, read in chunks, and for a live
|
|
||||||
// photo `<imageHash>:<videoHash>`.
|
|
||||||
const storedHash = async (stored: {
|
|
||||||
path: string;
|
|
||||||
videoPath?: string;
|
|
||||||
}): Promise<string> => {
|
|
||||||
await init();
|
|
||||||
const hashFile = async (path: string): Promise<string> => {
|
|
||||||
const state = chunkHashInit();
|
|
||||||
for await (const chunk of createReadStream(path)) {
|
|
||||||
chunkHashUpdate(state, chunk as Buffer);
|
|
||||||
}
|
|
||||||
return chunkHashFinal(state);
|
|
||||||
};
|
|
||||||
const hash = await hashFile(stored.path);
|
|
||||||
if (stored.videoPath === undefined) return hash;
|
|
||||||
return `${hash}:${await hashFile(stored.videoPath)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// A file's EXIF, XMP and dimensions as its JSON holds them: what
|
// A file's EXIF, XMP and dimensions as its JSON holds them: what
|
||||||
// `extractImageMetadata` found in its original, or why the original could not
|
// `extractImageMetadata` found in its original, or why the original could not
|
||||||
// be read.
|
// be read.
|
||||||
@@ -685,11 +675,13 @@ const runLockedBackup = async (
|
|||||||
// through the content cache/pools, as `Photo.download()` does, and fetch
|
// through the content cache/pools, as `Photo.download()` does, and fetch
|
||||||
// the optional thumbnails; a present file is left as is. With `verify`, a
|
// the optional thumbnails; a present file is left as is. With `verify`, a
|
||||||
// present original is hashed first, and one that does not match the hash
|
// present original is hashed first, and one that does not match the hash
|
||||||
// its metadata records is removed and fetched like a missing one. One
|
// its metadata records is downloaded again and replaced by
|
||||||
// that cannot be read is recorded as failed and left where it is.
|
// `replaceOriginal`, which leaves it as it was when that download fails
|
||||||
// `storeOriginal` does this for one original, and records its failure.
|
// or does not match either. One that cannot be read is recorded as failed
|
||||||
|
// and left where it is. `storeOriginal` does this for one original, and
|
||||||
|
// records its failure.
|
||||||
const storeOriginal = async (file: EnteFile): Promise<void> => {
|
const storeOriginal = async (file: EnteFile): Promise<void> => {
|
||||||
let stored = storedAtSavePath(downloadDirectory, file);
|
const stored = storedAtSavePath(downloadDirectory, file);
|
||||||
let mismatch = false;
|
let mismatch = false;
|
||||||
if (stored !== undefined && verify) {
|
if (stored !== undefined && verify) {
|
||||||
try {
|
try {
|
||||||
@@ -703,11 +695,6 @@ const runLockedBackup = async (
|
|||||||
);
|
);
|
||||||
mismatched++;
|
mismatched++;
|
||||||
mismatch = true;
|
mismatch = true;
|
||||||
rmSync(stored.path);
|
|
||||||
if (stored.videoPath !== undefined) {
|
|
||||||
rmSync(stored.videoPath);
|
|
||||||
}
|
|
||||||
stored = undefined;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(
|
log(
|
||||||
@@ -722,29 +709,25 @@ const runLockedBackup = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (stored !== undefined) {
|
if (stored !== undefined && !mismatch) {
|
||||||
skipped++;
|
skipped++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
log(`Fetching original ${file.metadata.title} (${file.id})...`);
|
log(`Fetching original ${file.metadata.title} (${file.id})...`);
|
||||||
// A fetched original is written straight to its save path (a
|
if (mismatch) {
|
||||||
// live photo beside it); only one that was already cached
|
await replaceOriginal(downloadDirectory, file, (dest) =>
|
||||||
// elsewhere is copied.
|
lib.fetchOriginal(file.id, dest),
|
||||||
const placed = await placeOriginal(
|
);
|
||||||
downloadDirectory,
|
} else {
|
||||||
file,
|
// A fetched original is written straight to its save path (a
|
||||||
(dest) => lib.original(file.id, dest),
|
// live photo beside it); only one that was already cached
|
||||||
);
|
// elsewhere is copied.
|
||||||
storedThisRun.add(file.id);
|
await placeOriginal(downloadDirectory, file, (dest) =>
|
||||||
// A copy from the cache is not checked as a download is and can
|
lib.original(file.id, dest),
|
||||||
// hold the same bad bytes, so what is put back after a mismatch
|
|
||||||
// is hashed too. A bad copy stays where it is and the file fails.
|
|
||||||
if (mismatch && (await storedHash(placed)) !== file.metadata.hash) {
|
|
||||||
throw new Error(
|
|
||||||
"the original put back does not match its content hash either",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
storedThisRun.add(file.id);
|
||||||
downloaded++;
|
downloaded++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(
|
log(
|
||||||
@@ -813,19 +796,18 @@ const runLockedBackup = 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), each with the file's
|
// every present original (this repairs stale ones), each with the file's
|
||||||
// ML data once an ML data fetch has completed, and its image metadata.
|
// ML data once an ML data fetch has completed, and its image metadata.
|
||||||
// When the fetch fails, a file with no cached ML data gets the reason
|
// When the fetch got a file's ML data but could not decrypt, gunzip or
|
||||||
// instead and is recorded as failed. The next run fetches its ML data
|
// parse it, or when the fetch failed, a file with no cached ML data gets
|
||||||
// again because none is cached.
|
// the reason instead and is recorded as failed. The next run fetches its
|
||||||
|
// ML data again because none is cached.
|
||||||
if (includeOriginals) {
|
if (includeOriginals) {
|
||||||
let mlDataFailure: unknown;
|
let mlDataFailure: unknown;
|
||||||
let mlDataError: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
log("Fetching ML data...");
|
log("Fetching ML data...");
|
||||||
await lib.fetchMLData();
|
await lib.fetchMLData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
mlDataFailure = err;
|
mlDataFailure = err;
|
||||||
mlDataError = describeError(err);
|
log(`FAILED ML data: ${describeError(err)}`);
|
||||||
log(`FAILED ML data: ${mlDataError}`);
|
|
||||||
}
|
}
|
||||||
for (const file of distinct.values()) {
|
for (const file of distinct.values()) {
|
||||||
if (lock.lost) throw lock.lost;
|
if (lock.lost) throw lock.lost;
|
||||||
@@ -842,13 +824,19 @@ const runLockedBackup = async (
|
|||||||
storedThisRun.has(file.id),
|
storedThisRun.has(file.id),
|
||||||
);
|
);
|
||||||
const mlData = await lib.mlData(file.id);
|
const mlData = await lib.mlData(file.id);
|
||||||
if (mlData === undefined && mlDataError !== undefined) {
|
const failure = lib.mlDataError(file.id) ?? mlDataFailure;
|
||||||
writeSidecar(path, file, { mlDataError }, image);
|
if (mlData === undefined && failure !== undefined) {
|
||||||
|
writeSidecar(
|
||||||
|
path,
|
||||||
|
file,
|
||||||
|
{ mlDataError: describeError(failure) },
|
||||||
|
image,
|
||||||
|
);
|
||||||
recordFailure(
|
recordFailure(
|
||||||
file.id,
|
file.id,
|
||||||
file.metadata.title,
|
file.metadata.title,
|
||||||
collectionName.get(file.collectionID) ?? "",
|
collectionName.get(file.collectionID) ?? "",
|
||||||
new Error("ML data", { cause: mlDataFailure }),
|
new Error("ML data", { cause: failure }),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
writeSidecar(path, file, { mlData }, image);
|
writeSidecar(path, file, { mlData }, image);
|
||||||
|
|||||||
+27
-12
@@ -125,15 +125,29 @@ const readLibraryClient = (client: Client): LibraryClient => ({
|
|||||||
|
|
||||||
// Open a library for a single point command: the aggressive background precache
|
// Open a library for a single point command: the aggressive background precache
|
||||||
// (issue #48) is off — a one-shot `collections` or `get` must not start
|
// (issue #48) is off — a one-shot `collections` or `get` must not start
|
||||||
// downloading the whole account — and the refresh interval is long so no second
|
// downloading the whole account — and no refresh starts as it opens or on a
|
||||||
// refresh fires mid-command.
|
// timer: each command asks for the one it needs, and `collections` needs none
|
||||||
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
|
// (issue #210). While a refresh runs, each collection whose files it has
|
||||||
|
// fetched is counted on stderr, unless `json` is set, so a large account shows
|
||||||
|
// progress rather than nothing.
|
||||||
|
const openReadLibrary = (
|
||||||
|
ctx: CliContext,
|
||||||
|
client: Client,
|
||||||
|
opts: { json?: boolean } = {},
|
||||||
|
): Promise<Library> =>
|
||||||
Library.open({
|
Library.open({
|
||||||
client: readLibraryClient(client),
|
client: readLibraryClient(client),
|
||||||
cacheDirectory: ctx.cacheDir,
|
cacheDirectory: ctx.cacheDir,
|
||||||
refreshIntervalSeconds: 3600,
|
refreshOnOpen: false,
|
||||||
|
refreshIntervalSeconds: Infinity,
|
||||||
precacheThumbnails: false,
|
precacheThumbnails: false,
|
||||||
precacheOriginals: false,
|
precacheOriginals: false,
|
||||||
|
onProgress: (event) => {
|
||||||
|
if (opts.json || event.status !== "progress") return;
|
||||||
|
ctx.stderr.write(
|
||||||
|
`Fetched the files of ${event.fetched} of ${event.total} collections\n`,
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
||||||
@@ -221,9 +235,10 @@ export const collectionsCommand = async (
|
|||||||
if (!client) return 3;
|
if (!client) return 3;
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client);
|
||||||
try {
|
try {
|
||||||
// Force a server round-trip and list in enumeration order (issue #36
|
// Ask the server for the collection list alone and list it in its
|
||||||
// amendment, issue #52): the pre-library CLI printed current state in
|
// order (issue #36 amendment, issues #52 and #210): the pre-library
|
||||||
// this order, not the albums projection's newest-first order.
|
// CLI printed current state in this order, not the albums
|
||||||
|
// projection's newest-first order.
|
||||||
const collections = await freshCollections(lib);
|
const collections = await freshCollections(lib);
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
@@ -264,7 +279,7 @@ export const filesCommand = async (
|
|||||||
const collectionID = parseID(ctx, "collection", opts.collection);
|
const collectionID = parseID(ctx, "collection", opts.collection);
|
||||||
if (collectionID === undefined) return 1;
|
if (collectionID === undefined) return 1;
|
||||||
|
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client, opts);
|
||||||
try {
|
try {
|
||||||
// Force a server round-trip and list in enumeration order (issue #36
|
// Force a server round-trip and list in enumeration order (issue #36
|
||||||
// amendment, issue #52). Each file prints from its own decrypted
|
// amendment, issue #52). Each file prints from its own decrypted
|
||||||
@@ -394,11 +409,11 @@ export const backupMetadataCommand = async (
|
|||||||
// Refresh first so the dump holds current account state, not what the
|
// Refresh first so the dump holds current account state, not what the
|
||||||
// cache last held; a failed refresh throws.
|
// cache last held; a failed refresh throws.
|
||||||
await lib.fresh();
|
await lib.fresh();
|
||||||
const { failedMLBatches } = await runMetadataBackup(lib, client, dir, {
|
const result = await runMetadataBackup(lib, client, dir, {
|
||||||
exif: opts.exif || opts.all,
|
exif: opts.exif || opts.all,
|
||||||
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
|
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
|
||||||
});
|
});
|
||||||
return failedMLBatches > 0 ? 1 : 0;
|
return result.filesWithMLDataError > 0 ? 1 : 0;
|
||||||
} finally {
|
} finally {
|
||||||
await lib.close();
|
await lib.close();
|
||||||
}
|
}
|
||||||
@@ -490,7 +505,7 @@ export const listMissingThumbnailsCommand = async (
|
|||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 3;
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client, opts);
|
||||||
try {
|
try {
|
||||||
// Refresh first so files added since the cache was written are
|
// Refresh first so files added since the cache was written are
|
||||||
// checked; a failed refresh throws.
|
// checked; a failed refresh throws.
|
||||||
@@ -535,7 +550,7 @@ export const fixMissingThumbnailsCommand = async (
|
|||||||
if (fileID === undefined) return 1;
|
if (fileID === undefined) return 1;
|
||||||
fileIDs.push(fileID);
|
fileIDs.push(fileID);
|
||||||
}
|
}
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client, opts);
|
||||||
try {
|
try {
|
||||||
// Refresh first so files added since the cache was written are found;
|
// Refresh first so files added since the cache was written are found;
|
||||||
// a failed refresh throws.
|
// a failed refresh throws.
|
||||||
|
|||||||
+16
-13
@@ -2,12 +2,16 @@
|
|||||||
//
|
//
|
||||||
// `collections`, `files --collection`, `get`, and `get-thumb` must answer for
|
// `collections`, `files --collection`, `get`, and `get-thumb` must answer for
|
||||||
// the account's state at the moment the command runs, not for whatever the
|
// the account's state at the moment the command runs, not for whatever the
|
||||||
// local cache last happened to hold (owner amendment, issue #36). Each helper
|
// local cache last happened to hold (owner amendment, issue #36). `files`,
|
||||||
// therefore forces a server round-trip through `Library.fresh()` and only then
|
// `get` and `get-thumb` therefore force a server round-trip through
|
||||||
// reads — so a collection, file, or metadata change made elsewhere is visible.
|
// `Library.fresh()` and only then read — so a collection, file, or metadata
|
||||||
|
// change made elsewhere is visible. `collections` asks the library for the
|
||||||
|
// collection list alone through `Library.freshCollections()` (issue #210): a
|
||||||
|
// refresh would also fetch the files of every collection, which on a large
|
||||||
|
// account takes long and which `collections` does not print.
|
||||||
//
|
//
|
||||||
// `collections` and `files` also list in the library's own enumeration order —
|
// `collections` lists in the server's order and `files` in the library's own
|
||||||
// `listCollections()`/`listFiles()`, the order the pre-library CLI printed —
|
// enumeration order (`listFiles()`), the orders the pre-library CLI printed,
|
||||||
// rather than the `albums`/`photos` projection's newest-first order, which
|
// rather than the `albums`/`photos` projection's newest-first order, which
|
||||||
// re-sorts the rows. The field values still come from each record's raw
|
// re-sorts the rows. The field values still come from each record's raw
|
||||||
// metadata via `cli-output.ts`.
|
// metadata via `cli-output.ts`.
|
||||||
@@ -17,23 +21,22 @@ import type { Photo, PhotosAPI } from "./library/index.js";
|
|||||||
|
|
||||||
// The slice of `Library` these helpers read. `Library` satisfies it
|
// The slice of `Library` these helpers read. `Library` satisfies it
|
||||||
// structurally; a test can drive them with a stand-in that records the
|
// structurally; a test can drive them with a stand-in that records the
|
||||||
// `fresh()` call and serves records in a known enumeration order.
|
// `fresh()` and `freshCollections()` calls and serves records in a known
|
||||||
|
// enumeration order.
|
||||||
export interface FreshReadLibrary {
|
export interface FreshReadLibrary {
|
||||||
fresh(): Promise<unknown>;
|
fresh(): Promise<unknown>;
|
||||||
listCollections(): Collection[];
|
freshCollections(): Promise<Collection[]>;
|
||||||
getCollection(id: number): Collection | undefined;
|
getCollection(id: number): Collection | undefined;
|
||||||
listFiles(collectionID: number): EnteFile[];
|
listFiles(collectionID: number): EnteFile[];
|
||||||
getFileByID(fileID: number): EnteFile | undefined;
|
getFileByID(fileID: number): EnteFile | undefined;
|
||||||
photos: Pick<PhotosAPI, "byID">;
|
photos: Pick<PhotosAPI, "byID">;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every live collection, current as of a forced refresh, in enumeration order.
|
// Every live collection, current as of one collections request, in the
|
||||||
export const freshCollections = async (
|
// server's order.
|
||||||
|
export const freshCollections = (
|
||||||
lib: FreshReadLibrary,
|
lib: FreshReadLibrary,
|
||||||
): Promise<Collection[]> => {
|
): Promise<Collection[]> => lib.freshCollections();
|
||||||
await lib.fresh();
|
|
||||||
return lib.listCollections();
|
|
||||||
};
|
|
||||||
|
|
||||||
// The files of one collection, current as of a forced refresh, in enumeration
|
// The files of one collection, current as of a forced refresh, in enumeration
|
||||||
// order. `undefined` (not an empty list) when the collection does not exist, so
|
// order. `undefined` (not an empty list) when the collection does not exist, so
|
||||||
|
|||||||
+8
-4
@@ -373,13 +373,17 @@ export class Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch machine-learning data (face detections + CLIP embeddings) for up
|
// Fetch machine-learning data (face detections + CLIP embeddings) for up
|
||||||
// to a batch of files, each decrypted with its own key. One request; the
|
// to a batch of files, each decrypted with its own key, and for each file
|
||||||
// library batches at `MLDATA_BATCH_SIZE` and schedules each batch through
|
// whose ML data did not decrypt, gunzip or parse, why not. One request;
|
||||||
// its metadata request pool.
|
// the library batches at `MLDATA_BATCH_SIZE` and schedules each batch
|
||||||
|
// through its metadata request pool.
|
||||||
async fetchMLData(args: {
|
async fetchMLData(args: {
|
||||||
fileIDs: number[];
|
fileIDs: number[];
|
||||||
fileKeys: Map<number, Uint8Array>;
|
fileKeys: Map<number, Uint8Array>;
|
||||||
}): Promise<Map<number, MLData>> {
|
}): Promise<{
|
||||||
|
payloads: Map<number, MLData>;
|
||||||
|
errors: Map<number, unknown>;
|
||||||
|
}> {
|
||||||
this.assertLoggedIn();
|
this.assertLoggedIn();
|
||||||
return fetchMLDataBatch(this.api, args.fileIDs, args.fileKeys);
|
return fetchMLDataBatch(this.api, args.fileIDs, args.fileKeys);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -199,11 +199,13 @@ const isRunning = (pid: number): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Delete the temp files a killed process left in `dir`: the writer's
|
// Delete the temp files a killed process left in `dir`: the writer's
|
||||||
// `.quak-<pid>-<random>.tmp` and the backup copy's
|
// `.quak-<pid>-<random>.tmp`, the backup copy's
|
||||||
// `.quak-backup-<name>-<pid>-<random>.tmp`. Only files whose process is no
|
// `.quak-backup-<name>-<pid>-<random>.tmp`, and the image and video of a live
|
||||||
// longer running are removed, so another process writing into the same
|
// photo downloaded to replace a stored one, `.quak-<pid>-<random>.<ext>` (see
|
||||||
// directory keeps its own. A reused process ID can only keep a leftover a while
|
// `replaceOriginal`). Only files whose process is no longer running are
|
||||||
// longer, never remove a live one.
|
// removed, so another process writing into the same directory keeps its own. A
|
||||||
|
// reused process ID can only keep a leftover a while longer, never remove a
|
||||||
|
// live one.
|
||||||
export const removeLeftoverTempFiles = (dir: string): void => {
|
export const removeLeftoverTempFiles = (dir: string): void => {
|
||||||
let names: string[];
|
let names: string[];
|
||||||
try {
|
try {
|
||||||
@@ -212,7 +214,9 @@ export const removeLeftoverTempFiles = (dir: string): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.tmp$/.exec(name);
|
const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.[0-9A-Za-z]+$/.exec(
|
||||||
|
name,
|
||||||
|
);
|
||||||
if (match && !isRunning(Number(match[1]))) {
|
if (match && !isRunning(Number(match[1]))) {
|
||||||
rmSync(join(dir, name), { force: true });
|
rmSync(join(dir, name), { force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-2
@@ -13,6 +13,8 @@
|
|||||||
// originals, the thumbnail pool for thumbnails. The pool limits concurrency,
|
// originals, the thumbnail pool for thumbnails. The pool limits concurrency,
|
||||||
// orders on-demand work ahead of background, and dedups by key so a fileID
|
// orders on-demand work ahead of background, and dedups by key so a fileID
|
||||||
// requested twice while the first is still in flight downloads once.
|
// requested twice while the first is still in flight downloads once.
|
||||||
|
// `fetchOriginal` passes no key, so it is not shared with a fetch of the same
|
||||||
|
// file already in flight.
|
||||||
//
|
//
|
||||||
// Integrity. The reused streaming decrypt is the enforced guarantee: every
|
// Integrity. The reused streaming decrypt is the enforced guarantee: every
|
||||||
// chunk is authenticated and the writer renames the file into place only once
|
// chunk is authenticated and the writer renames the file into place only once
|
||||||
@@ -27,9 +29,10 @@
|
|||||||
// It also names each original's save path under the download directory
|
// It also names each original's save path under the download directory
|
||||||
// (`savePath`), where `Photo.download()` and `lib.backup()` put it. A copy in
|
// (`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
|
// the cache does not count as saved there, but is copied there rather than
|
||||||
// fetched again.
|
// fetched again. An original stored there whose bytes do not match its content
|
||||||
|
// hash is replaced only by a download that does (`replaceOriginal`).
|
||||||
|
|
||||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
|
||||||
import {
|
import {
|
||||||
chmod,
|
chmod,
|
||||||
copyFile,
|
copyFile,
|
||||||
@@ -44,6 +47,12 @@ import {
|
|||||||
import { basename, dirname, extname, join } from "node:path";
|
import { basename, dirname, extname, join } from "node:path";
|
||||||
|
|
||||||
import type { ApiClient } from "../api/client.js";
|
import type { ApiClient } from "../api/client.js";
|
||||||
|
import {
|
||||||
|
chunkHashFinal,
|
||||||
|
chunkHashInit,
|
||||||
|
chunkHashUpdate,
|
||||||
|
init,
|
||||||
|
} from "../crypto/index.js";
|
||||||
import {
|
import {
|
||||||
downloadFile,
|
downloadFile,
|
||||||
downloadThumbnail,
|
downloadThumbnail,
|
||||||
@@ -349,6 +358,26 @@ export const storedAtSavePath = (
|
|||||||
return storedOriginal(dirname(path), basename(path, extname(path)), file);
|
return storedOriginal(dirname(path), basename(path, extname(path)), file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The content hash of the original stored at `stored`, computed as the download
|
||||||
|
// check computes it: over each file's bytes, read in chunks, and for a live
|
||||||
|
// photo `<imageHash>:<videoHash>`.
|
||||||
|
export const storedHash = async (stored: {
|
||||||
|
path: string;
|
||||||
|
videoPath?: string;
|
||||||
|
}): Promise<string> => {
|
||||||
|
await init();
|
||||||
|
const hashFile = async (path: string): Promise<string> => {
|
||||||
|
const state = chunkHashInit();
|
||||||
|
for await (const chunk of createReadStream(path)) {
|
||||||
|
chunkHashUpdate(state, chunk as Buffer);
|
||||||
|
}
|
||||||
|
return chunkHashFinal(state);
|
||||||
|
};
|
||||||
|
const hash = await hashFile(stored.path);
|
||||||
|
if (stored.videoPath === undefined) return hash;
|
||||||
|
return `${hash}:${await hashFile(stored.videoPath)}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
|
// 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
|
// `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
|
// download writer, the temp file is fsynced before the rename and the directory
|
||||||
@@ -405,6 +434,62 @@ export const placeOriginal = async (
|
|||||||
return { path, videoPath };
|
return { path, videoPath };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Replace the original of `file` stored at its save path under `root` with a
|
||||||
|
// new copy, keeping the stored one until the copy is complete and its hash,
|
||||||
|
// computed as `storedHash` computes it, is the content hash the file's metadata
|
||||||
|
// records. `get` is given a temporary file beside the save path and writes the
|
||||||
|
// copy there, a live photo as its image and its video beside that file, each
|
||||||
|
// with its own extension. Only a copy that matches is renamed over the stored
|
||||||
|
// one, and a live photo's image and video are both hashed before either is
|
||||||
|
// renamed. The image is renamed first: should the run stop, or the video's
|
||||||
|
// rename fail, before the video is renamed, the new image is left beside the
|
||||||
|
// old video, and a later backup with `verify` replaces the video if the video
|
||||||
|
// was the part that did not match. When `get` fails or the copy does not match,
|
||||||
|
// the temporary files are removed and the stored original stays as it was.
|
||||||
|
export const replaceOriginal = async (
|
||||||
|
root: string,
|
||||||
|
file: EnteFile,
|
||||||
|
get: (dest: string) => Promise<{ path: string; videoPath?: string }>,
|
||||||
|
): Promise<{ path: string; videoPath?: string }> => {
|
||||||
|
const dest = savePath(root, file);
|
||||||
|
const dir = dirname(dest);
|
||||||
|
// Named as the download writer names its temporary files; a live photo's
|
||||||
|
// image and video get this name with their own extensions. The next
|
||||||
|
// backup removes any that a killed run leaves (`removeLeftoverTempFiles`).
|
||||||
|
const tmp = join(
|
||||||
|
dir,
|
||||||
|
`.quak-${process.pid}-${Math.random().toString(36).slice(2)}.tmp`,
|
||||||
|
);
|
||||||
|
let got: { path: string; videoPath?: string } | undefined;
|
||||||
|
try {
|
||||||
|
got = await get(tmp);
|
||||||
|
if ((await storedHash(got)) !== file.metadata.hash) {
|
||||||
|
throw new Error(
|
||||||
|
"the downloaded original does not match its content hash either",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (got.videoPath === undefined) {
|
||||||
|
await rename(got.path, dest);
|
||||||
|
await fsyncPath(dir);
|
||||||
|
return { path: dest };
|
||||||
|
}
|
||||||
|
const path = withExtension(dest, extname(got.path));
|
||||||
|
const videoPath = withExtension(dest, extname(got.videoPath));
|
||||||
|
await rename(got.path, path);
|
||||||
|
await rename(got.videoPath, videoPath);
|
||||||
|
const name = basename(dest, extname(dest));
|
||||||
|
await writeLivePhotoJSON(dir, name, { path, videoPath });
|
||||||
|
return { path, videoPath };
|
||||||
|
} finally {
|
||||||
|
// Whatever was not renamed into place. A failure to remove it must
|
||||||
|
// not replace the error that explains what went wrong.
|
||||||
|
for (const path of [tmp, got?.path, got?.videoPath]) {
|
||||||
|
if (path === undefined) continue;
|
||||||
|
await rm(path, { force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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;
|
||||||
@@ -543,6 +628,35 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch an original through the content pool to `destination`, a live
|
||||||
|
// photo as its image and its video beside it, and return where it landed.
|
||||||
|
// Unlike `backupOriginal`, it fetches even when the cache or the save path
|
||||||
|
// already holds the original, and it records nothing: what it writes is
|
||||||
|
// the caller's. It is not shared with a fetch of the same file already
|
||||||
|
// under way, which writes somewhere else.
|
||||||
|
async fetchOriginal(
|
||||||
|
fileID: number,
|
||||||
|
destination: string,
|
||||||
|
): Promise<{ path: string; videoPath?: string }> {
|
||||||
|
const file = this.getFile(fileID);
|
||||||
|
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
|
||||||
|
return this.pools.content.run(
|
||||||
|
async () => {
|
||||||
|
const fetched = await this.fetchInto(
|
||||||
|
file,
|
||||||
|
destination,
|
||||||
|
"original",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
for (const path of [fetched.path, fetched.videoPath]) {
|
||||||
|
if (path !== undefined) await chmod(path, FILE_MODE);
|
||||||
|
}
|
||||||
|
return fetched;
|
||||||
|
},
|
||||||
|
{ priority: "on-demand" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async ensure(args: EnsureOptions): Promise<EnsureResult[]> {
|
async ensure(args: EnsureOptions): Promise<EnsureResult[]> {
|
||||||
return this.ensureThumbnails(args);
|
return this.ensureThumbnails(args);
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-15
@@ -18,6 +18,16 @@
|
|||||||
// the caller (the default reads stay silent and serve the last good copy). The
|
// the caller (the default reads stay silent and serve the last good copy). The
|
||||||
// default methods and the background loop are unchanged.
|
// default methods and the background loop are unchanged.
|
||||||
//
|
//
|
||||||
|
// `freshCollections()` is the fresh read of the collection list alone (issue
|
||||||
|
// #210): one collections request, no collection's files, and nothing stored,
|
||||||
|
// for a caller such as `quak collections` that needs no more. A caller that
|
||||||
|
// asks for each refresh it needs can also open the library with
|
||||||
|
// `refreshOnOpen: false`, so that `open()` starts none.
|
||||||
|
//
|
||||||
|
// A refresh reports through `onProgress` each collection whose files it has
|
||||||
|
// fetched, so a large account, whose first refresh fetches the files of every
|
||||||
|
// collection, shows progress.
|
||||||
|
//
|
||||||
// A refresh stages all of its network work first and only mutates the store
|
// A refresh stages all of its network work first and only mutates the store
|
||||||
// once every fetch has succeeded. A refresh that fails partway therefore never
|
// once every fetch has succeeded. A refresh that fails partway therefore never
|
||||||
// becomes visible to reads: the last good snapshot stays in place, and the
|
// becomes visible to reads: the last good snapshot stays in place, and the
|
||||||
@@ -89,6 +99,7 @@ export {
|
|||||||
} from "./content.js";
|
} from "./content.js";
|
||||||
export { type MLDataAPI, type SimilarResult } from "./mlsearch.js";
|
export { type MLDataAPI, type SimilarResult } from "./mlsearch.js";
|
||||||
import type { CollectionsPage, FilesPage } from "../client.js";
|
import type { CollectionsPage, FilesPage } from "../client.js";
|
||||||
|
import { describeError } from "../errors.js";
|
||||||
import { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js";
|
import { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js";
|
||||||
import type { Collection, EnteFile } from "../model/types.js";
|
import type { Collection, EnteFile } from "../model/types.js";
|
||||||
import { runBackup, type BackupOptions, type BackupResult } from "../backup.js";
|
import { runBackup, type BackupOptions, type BackupResult } from "../backup.js";
|
||||||
@@ -138,12 +149,16 @@ export interface LibraryClient {
|
|||||||
sinceTime: number;
|
sinceTime: number;
|
||||||
}): Promise<FilesPage>;
|
}): Promise<FilesPage>;
|
||||||
// Fetch ML data (face detections + CLIP embeddings) for up to a batch of
|
// Fetch ML data (face detections + CLIP embeddings) for up to a batch of
|
||||||
// files. Optional: a client without it simply disables ML fetching, leaving
|
// files, and for each file whose ML data did not decrypt, gunzip or parse,
|
||||||
// the metadata refresh untouched.
|
// why not. Optional: a client without it simply disables ML fetching,
|
||||||
|
// leaving the metadata refresh untouched.
|
||||||
fetchMLData?(args: {
|
fetchMLData?(args: {
|
||||||
fileIDs: number[];
|
fileIDs: number[];
|
||||||
fileKeys: Map<number, Uint8Array>;
|
fileKeys: Map<number, Uint8Array>;
|
||||||
}): Promise<Map<number, MLData>>;
|
}): Promise<{
|
||||||
|
payloads: Map<number, MLData>;
|
||||||
|
errors: Map<number, unknown>;
|
||||||
|
}>;
|
||||||
// The byte source for the on-disk content cache. Optional so a mock client
|
// The byte source for the on-disk content cache. Optional so a mock client
|
||||||
// that only serves metadata still satisfies the interface; when absent (and
|
// that only serves metadata still satisfies the interface; when absent (and
|
||||||
// no explicit `contentSource` is passed to `open`) the content cache is
|
// no explicit `contentSource` is passed to `open`) the content cache is
|
||||||
@@ -154,7 +169,11 @@ export interface LibraryClient {
|
|||||||
// A progress event for one unit of background work. A metadata "refresh" or an
|
// A progress event for one unit of background work. A metadata "refresh" or an
|
||||||
// ML "fetchMLData" pass each fire "started" before their network work and then
|
// ML "fetchMLData" pass each fire "started" before their network work and then
|
||||||
// exactly one of "done" or "failed"; "failed" carries the error message and
|
// exactly one of "done" or "failed"; "failed" carries the error message and
|
||||||
// an ML "done" reports how many payloads it stored. The precache fills
|
// an ML "done" reports how many payloads it stored and, in `mlDataErrors`,
|
||||||
|
// each file whose ML data did not decrypt, gunzip or parse, with the reason.
|
||||||
|
// In between, a refresh fires "progress" after each collection whose files it
|
||||||
|
// fetched: `fetched` is how many collections it has fetched the files of so
|
||||||
|
// far, `total` how many it will. The precache fills
|
||||||
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
|
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
|
||||||
// each sweep that has work, "done" reporting the count newly cached.
|
// each sweep that has work, "done" reporting the count newly cached.
|
||||||
export interface RefreshEvent {
|
export interface RefreshEvent {
|
||||||
@@ -163,9 +182,11 @@ export interface RefreshEvent {
|
|||||||
| "fetchMLData"
|
| "fetchMLData"
|
||||||
| "precacheThumbnails"
|
| "precacheThumbnails"
|
||||||
| "precacheOriginals";
|
| "precacheOriginals";
|
||||||
status: "started" | "done" | "failed";
|
status: "started" | "progress" | "done" | "failed";
|
||||||
error?: string;
|
error?: string;
|
||||||
fetched?: number;
|
fetched?: number;
|
||||||
|
total?: number;
|
||||||
|
mlDataErrors?: { fileID: number; error: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RefreshProgressCallback = (event: RefreshEvent) => void;
|
export type RefreshProgressCallback = (event: RefreshEvent) => void;
|
||||||
@@ -184,6 +205,10 @@ export interface LibraryOptions {
|
|||||||
// `Infinity` starts none: the library then refreshes only as it opens and
|
// `Infinity` starts none: the library then refreshes only as it opens and
|
||||||
// when `fresh()` or `backup()` asks.
|
// when `fresh()` or `backup()` asks.
|
||||||
refreshIntervalSeconds?: number;
|
refreshIntervalSeconds?: number;
|
||||||
|
// Whether `open()` starts a refresh. Defaults to true. With false, `open()`
|
||||||
|
// returns at once onto what the cache holds, empty or not, and a refresh
|
||||||
|
// starts only when `fresh()` or `backup()` asks or the timer fires.
|
||||||
|
refreshOnOpen?: boolean;
|
||||||
onProgress?: RefreshProgressCallback;
|
onProgress?: RefreshProgressCallback;
|
||||||
// The bounded request pools (issue #45), shared by the ML-data fetch (the
|
// The bounded request pools (issue #45), shared by the ML-data fetch (the
|
||||||
// metadata pool) and the content cache. Defaults to a fresh set at the
|
// metadata pool) and the content cache. Defaults to a fresh set at the
|
||||||
@@ -225,6 +250,11 @@ export interface LibraryStatus {
|
|||||||
lastMLFetchAt?: number;
|
lastMLFetchAt?: number;
|
||||||
// The most recent ML fetch pass's error, set only while it failed.
|
// The most recent ML fetch pass's error, set only while it failed.
|
||||||
lastMLError?: string;
|
lastMLError?: string;
|
||||||
|
// Each file whose ML data the last ML fetch pass that succeeded got but
|
||||||
|
// could not decrypt, gunzip or parse, with the reason. A pass still
|
||||||
|
// running leaves it as it is. A pass that fails leaves it as it was. Such
|
||||||
|
// ML data is not cached, so the next pass asks for it again.
|
||||||
|
mlDataErrors: { fileID: number; error: string }[];
|
||||||
// ML payloads stored on disk and CLIP embeddings in the index; undefined
|
// ML payloads stored on disk and CLIP embeddings in the index; undefined
|
||||||
// when ML fetching is disabled.
|
// when ML fetching is disabled.
|
||||||
mlStored?: number;
|
mlStored?: number;
|
||||||
@@ -292,6 +322,12 @@ export class Library {
|
|||||||
private lastError?: string;
|
private lastError?: string;
|
||||||
private lastMLFetchAt?: number;
|
private lastMLFetchAt?: number;
|
||||||
private lastMLError?: string;
|
private lastMLError?: string;
|
||||||
|
// For each file whose ML data the last ML fetch pass that succeeded got
|
||||||
|
// but could not decrypt, gunzip or parse, why not, by fileID. A pass
|
||||||
|
// gathers its own and puts them here when it ends without failing, so
|
||||||
|
// `status()` and `backup()` never see the list of a pass that is still
|
||||||
|
// running or that failed. Kept in RAM only, never in the cache.
|
||||||
|
private mlDataErrors = new Map<number, unknown>();
|
||||||
// The plain-record projection as of the last refresh, and the GUI change
|
// The plain-record projection as of the last refresh, and the GUI change
|
||||||
// subscribers. A refresh that alters the projection notifies each with the
|
// subscribers. A refresh that alters the projection notifies each with the
|
||||||
// delta; `lastRecords` is kept current every refresh so a subscriber that
|
// delta; `lastRecords` is kept current every refresh so a subscriber that
|
||||||
@@ -363,7 +399,8 @@ export class Library {
|
|||||||
// then opens empty with the failure recorded in `status()`. With an
|
// then opens empty with the failure recorded in `status()`. With an
|
||||||
// existing cache the first refresh runs in the background and `open()`
|
// existing cache the first refresh runs in the background and `open()`
|
||||||
// returns as soon as the cached data is ready — an unreachable server does
|
// returns as soon as the cached data is ready — an unreachable server does
|
||||||
// not block opening.
|
// not block opening. With `refreshOnOpen: false` there is no first
|
||||||
|
// refresh: `open()` returns onto the cache as it is.
|
||||||
static async open(opts: LibraryOptions): Promise<Library> {
|
static async open(opts: LibraryOptions): Promise<Library> {
|
||||||
const { userID } = opts.client.whoami();
|
const { userID } = opts.client.whoami();
|
||||||
const cacheDirectory =
|
const cacheDirectory =
|
||||||
@@ -466,7 +503,10 @@ export class Library {
|
|||||||
// refresh.
|
// refresh.
|
||||||
precache?.start();
|
precache?.start();
|
||||||
|
|
||||||
if (store.loadedFromDisk) {
|
if (opts.refreshOnOpen === false) {
|
||||||
|
// The caller asks for each refresh it needs.
|
||||||
|
lib.scheduleNext();
|
||||||
|
} else if (store.loadedFromDisk) {
|
||||||
// An existing copy already answers reads; refresh in the background
|
// An existing copy already answers reads; refresh in the background
|
||||||
// and start the interval once that first cycle settles.
|
// and start the interval once that first cycle settles.
|
||||||
void lib.runRefresh().then(() => lib.scheduleNext());
|
void lib.runRefresh().then(() => lib.scheduleNext());
|
||||||
@@ -542,6 +582,7 @@ export class Library {
|
|||||||
lastError: this.lastError,
|
lastError: this.lastError,
|
||||||
lastMLFetchAt: this.lastMLFetchAt,
|
lastMLFetchAt: this.lastMLFetchAt,
|
||||||
lastMLError: this.lastMLError,
|
lastMLError: this.lastMLError,
|
||||||
|
mlDataErrors: this.describeMLDataErrors(),
|
||||||
mlStored: ml?.stored,
|
mlStored: ml?.stored,
|
||||||
mlIndexed: ml?.indexed,
|
mlIndexed: ml?.indexed,
|
||||||
originalsUsedBytes: originals?.usedBytes,
|
originalsUsedBytes: originals?.usedBytes,
|
||||||
@@ -569,6 +610,21 @@ export class Library {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The fresh read of the collection list alone (issue #210): every live
|
||||||
|
// collection that decrypts, in the server's order, from one collections
|
||||||
|
// request. Unlike a refresh, it fetches no collection's files, which on a
|
||||||
|
// large account take long. Nothing is stored: a refresh fetches a
|
||||||
|
// collection's files only when the collection is new or has changed since
|
||||||
|
// the stored copy, so a collection stored here without its files would
|
||||||
|
// never have them fetched.
|
||||||
|
async freshCollections(): Promise<Collection[]> {
|
||||||
|
if (this.closed) throw new Error("the library is closed");
|
||||||
|
const { collections } = await this.client.collectionsSince({
|
||||||
|
sinceTime: 0,
|
||||||
|
});
|
||||||
|
return collections;
|
||||||
|
}
|
||||||
|
|
||||||
// Back up every in-scope file to `opts.downloadDirectory`, or else the
|
// Back up every in-scope file to `opts.downloadDirectory`, or else the
|
||||||
// library's, each original at its save path, with a durable failure
|
// library's, each original at its save path, with a durable failure
|
||||||
// ledger (issue #51). Takes the lock in that directory first, unless the
|
// ledger (issue #51). Takes the lock in that directory first, unless the
|
||||||
@@ -606,10 +662,13 @@ export class Library {
|
|||||||
undecryptable: () => this.store.listUndecryptable(),
|
undecryptable: () => this.store.listUndecryptable(),
|
||||||
original: (fileID, destination) =>
|
original: (fileID, destination) =>
|
||||||
cache!.backupOriginal(fileID, destination),
|
cache!.backupOriginal(fileID, destination),
|
||||||
|
fetchOriginal: (fileID, destination) =>
|
||||||
|
cache!.fetchOriginal(fileID, destination),
|
||||||
thumbnail: (fileID) => cache!.thumbnail(fileID),
|
thumbnail: (fileID) => cache!.thumbnail(fileID),
|
||||||
contentConcurrency: this.pools.content.concurrency,
|
contentConcurrency: this.pools.content.concurrency,
|
||||||
fetchMLData: () => this.fetchMLDataNow(),
|
fetchMLData: () => this.fetchMLDataNow(),
|
||||||
mlData: (fileID) => this.mldata.forFile({ fileID }),
|
mlData: (fileID) => this.mldata.forFile({ fileID }),
|
||||||
|
mlDataError: (fileID) => this.mlDataErrors.get(fileID),
|
||||||
},
|
},
|
||||||
{ ...opts, downloadDirectory },
|
{ ...opts, downloadDirectory },
|
||||||
);
|
);
|
||||||
@@ -719,18 +778,28 @@ export class Library {
|
|||||||
// Stage per-collection file diffs. A collection's files are
|
// Stage per-collection file diffs. A collection's files are
|
||||||
// re-enumerated only when its updationTime has advanced past the cached
|
// re-enumerated only when its updationTime has advanced past the cached
|
||||||
// copy; an unchanged album's file list cannot have changed. New
|
// copy; an unchanged album's file list cannot have changed. New
|
||||||
// collections enumerate from the beginning of time.
|
// collections enumerate from the beginning of time. Each collection
|
||||||
|
// whose files are fetched is reported, with how many there are to
|
||||||
|
// fetch.
|
||||||
|
const changedCollections = page.collections.filter((collection) => {
|
||||||
|
const known = this.store.getCollection(collection.id);
|
||||||
|
return !known || collection.updationTime > known.updationTime;
|
||||||
|
});
|
||||||
const filePages: { collectionID: number; page: FilesPage }[] = [];
|
const filePages: { collectionID: number; page: FilesPage }[] = [];
|
||||||
for (const collection of page.collections) {
|
for (const collection of changedCollections) {
|
||||||
const known = this.store.getCollection(collection.id);
|
const known = this.store.getCollection(collection.id);
|
||||||
if (known && collection.updationTime <= known.updationTime)
|
|
||||||
continue;
|
|
||||||
const filePage = await this.client.filesSince({
|
const filePage = await this.client.filesSince({
|
||||||
collectionID: collection.id,
|
collectionID: collection.id,
|
||||||
collectionKey: collection.key,
|
collectionKey: collection.key,
|
||||||
sinceTime: known ? known.updationTime : 0,
|
sinceTime: known ? known.updationTime : 0,
|
||||||
});
|
});
|
||||||
filePages.push({ collectionID: collection.id, page: filePage });
|
filePages.push({ collectionID: collection.id, page: filePage });
|
||||||
|
this.emit({
|
||||||
|
operation: "refresh",
|
||||||
|
status: "progress",
|
||||||
|
fetched: filePages.length,
|
||||||
|
total: changedCollections.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network work done; commit to the store and persist only if something
|
// Network work done; commit to the store and persist only if something
|
||||||
@@ -836,8 +905,11 @@ export class Library {
|
|||||||
|
|
||||||
// One ML fetch pass: fetch, decrypt and store the ML data for every file
|
// One ML fetch pass: fetch, decrypt and store the ML data for every file
|
||||||
// the store knows about that is not cached (or whose `updationTime` has
|
// the store knows about that is not cached (or whose `updationTime` has
|
||||||
// advanced), through the metadata pool, and update the CLIP index. A
|
// advanced), through the metadata pool, and update the CLIP index. For a
|
||||||
// failure is reported through `status()` and `onProgress`, then thrown.
|
// file whose ML data does not decrypt, gunzip or parse, it gathers the
|
||||||
|
// reason instead, and puts the reasons it gathered in `mlDataErrors` when
|
||||||
|
// it ends without failing. A failure leaves `mlDataErrors` as it was and
|
||||||
|
// is reported through `status()` and `onProgress`, then thrown.
|
||||||
private async runMLFetch(): Promise<void> {
|
private async runMLFetch(): Promise<void> {
|
||||||
const mldata = this.mlStore;
|
const mldata = this.mlStore;
|
||||||
// Bind so the call keeps the client as its receiver when invoked
|
// Bind so the call keeps the client as its receiver when invoked
|
||||||
@@ -847,7 +919,14 @@ export class Library {
|
|||||||
|
|
||||||
const files = this.uniqueFiles();
|
const files = this.uniqueFiles();
|
||||||
const needed = mldata.neededFor(files);
|
const needed = mldata.neededFor(files);
|
||||||
if (needed.length === 0) return;
|
// This pass's reasons, put in `this.mlDataErrors` when it ends without
|
||||||
|
// failing. A file whose ML data did not decode in the pass before is
|
||||||
|
// still among `needed`, so this pass gives its reason anew.
|
||||||
|
const mlDataErrors = new Map<number, unknown>();
|
||||||
|
if (needed.length === 0) {
|
||||||
|
this.mlDataErrors = mlDataErrors;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.emit({ operation: "fetchMLData", status: "started" });
|
this.emit({ operation: "fetchMLData", status: "started" });
|
||||||
try {
|
try {
|
||||||
@@ -862,20 +941,25 @@ export class Library {
|
|||||||
for (let i = 0; i < needed.length; i += MLDATA_BATCH_SIZE) {
|
for (let i = 0; i < needed.length; i += MLDATA_BATCH_SIZE) {
|
||||||
if (this.closed) break;
|
if (this.closed) break;
|
||||||
const batch = needed.slice(i, i + MLDATA_BATCH_SIZE);
|
const batch = needed.slice(i, i + MLDATA_BATCH_SIZE);
|
||||||
const payloads = await this.pools.metadata.run(
|
const { payloads, errors } = await this.pools.metadata.run(
|
||||||
() => fetchMLData({ fileIDs: batch, fileKeys }),
|
() => fetchMLData({ fileIDs: batch, fileKeys }),
|
||||||
{ priority: "background" },
|
{ priority: "background" },
|
||||||
);
|
);
|
||||||
|
for (const [id, error] of errors) {
|
||||||
|
mlDataErrors.set(id, error);
|
||||||
|
}
|
||||||
stored += (await mldata.storeFetched(payloads, updation))
|
stored += (await mldata.storeFetched(payloads, updation))
|
||||||
.stored;
|
.stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.mlDataErrors = mlDataErrors;
|
||||||
this.lastMLFetchAt = Date.now();
|
this.lastMLFetchAt = Date.now();
|
||||||
this.lastMLError = undefined;
|
this.lastMLError = undefined;
|
||||||
this.emit({
|
this.emit({
|
||||||
operation: "fetchMLData",
|
operation: "fetchMLData",
|
||||||
status: "done",
|
status: "done",
|
||||||
fetched: stored,
|
fetched: stored,
|
||||||
|
mlDataErrors: this.describeMLDataErrors(),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
@@ -885,6 +969,14 @@ export class Library {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `mlDataErrors` as `status()` and the ML "done" event give it.
|
||||||
|
private describeMLDataErrors(): { fileID: number; error: string }[] {
|
||||||
|
return [...this.mlDataErrors].map(([fileID, err]) => ({
|
||||||
|
fileID,
|
||||||
|
error: describeError(err),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// The distinct files the store holds, one entry per fileID (a file in
|
// The distinct files the store holds, one entry per fileID (a file in
|
||||||
// several collections shares its ML data), each carrying the key and the
|
// several collections shares its ML data), each carrying the key and the
|
||||||
// newest `updationTime` seen across its memberships.
|
// newest `updationTime` seen across its memberships.
|
||||||
|
|||||||
+14
-8
@@ -2,6 +2,7 @@ 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 type { Client } from "./client.js";
|
import type { Client } from "./client.js";
|
||||||
|
import { describeError } from "./errors.js";
|
||||||
import { readExifTags } from "./exif.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";
|
||||||
@@ -91,14 +92,15 @@ const extractExif = async (
|
|||||||
// of plain JSON: account, per-collection, and per-file records including the
|
// of plain JSON: account, per-collection, and per-file records including the
|
||||||
// private and public magic metadata and (by default) the ML data. Collections
|
// private and public magic metadata and (by default) the ML data. Collections
|
||||||
// and files are enumerated from the library's cache, which the caller refreshes
|
// and files are enumerated from the library's cache, which the caller refreshes
|
||||||
// first. Returns how many ML data requests failed; their files are still
|
// first. Returns how many files have no ML data because their ML data request
|
||||||
|
// failed or their ML data did not decrypt, gunzip or parse; they are still
|
||||||
// written, with `mlDataError` in place of `mlData`.
|
// written, with `mlDataError` in place of `mlData`.
|
||||||
export const runMetadataBackup = async (
|
export const runMetadataBackup = async (
|
||||||
lib: Library,
|
lib: Library,
|
||||||
client: Client,
|
client: Client,
|
||||||
outDir: string,
|
outDir: string,
|
||||||
opts?: MetadataBackupOptions,
|
opts?: MetadataBackupOptions,
|
||||||
): Promise<{ failedMLBatches: number }> => {
|
): Promise<{ filesWithMLDataError: number }> => {
|
||||||
const log = opts?.onProgress ?? (() => {});
|
const log = opts?.onProgress ?? (() => {});
|
||||||
const wantExif = opts?.exif ?? false;
|
const wantExif = opts?.exif ?? false;
|
||||||
|
|
||||||
@@ -164,24 +166,28 @@ export const runMetadataBackup = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One failed request (retries exhausted) must not end the dump: its files
|
// One failed request (retries exhausted) must not end the dump: its files
|
||||||
// get the reason in `mlDataError` and the other batches go on.
|
// get the reason in `mlDataError` and the other batches go on. So does a
|
||||||
|
// file whose ML data does not decrypt, gunzip or parse.
|
||||||
log("Fetching ML data (face detections, CLIP embeddings)...");
|
log("Fetching ML data (face detections, CLIP embeddings)...");
|
||||||
const mlDataMap = new Map<number, MLData>();
|
const mlDataMap = new Map<number, MLData>();
|
||||||
const mlDataErrors = new Map<number, string>();
|
const mlDataErrors = new Map<number, string>();
|
||||||
let failedMLBatches = 0;
|
|
||||||
const fileIDs = [...fileKeys.keys()];
|
const fileIDs = [...fileKeys.keys()];
|
||||||
for (let i = 0; i < fileIDs.length; i += MLDATA_BATCH_SIZE) {
|
for (let i = 0; i < fileIDs.length; i += MLDATA_BATCH_SIZE) {
|
||||||
const batch = fileIDs.slice(i, i + MLDATA_BATCH_SIZE);
|
const batch = fileIDs.slice(i, i + MLDATA_BATCH_SIZE);
|
||||||
try {
|
try {
|
||||||
const result = await fetchMLDataBatch(
|
const { payloads, errors } = await fetchMLDataBatch(
|
||||||
client.getApiClient(),
|
client.getApiClient(),
|
||||||
batch,
|
batch,
|
||||||
fileKeys,
|
fileKeys,
|
||||||
);
|
);
|
||||||
for (const [id, payload] of result) mlDataMap.set(id, payload);
|
for (const [id, payload] of payloads) mlDataMap.set(id, payload);
|
||||||
|
for (const [id, err] of errors) {
|
||||||
|
const reason = describeError(err);
|
||||||
|
log(`ML data of file ${id} could not be read: ${reason}`);
|
||||||
|
mlDataErrors.set(id, reason);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
failedMLBatches++;
|
|
||||||
log(
|
log(
|
||||||
`ML data request for ${batch.length} file(s) failed: ${reason}`,
|
`ML data request for ${batch.length} file(s) failed: ${reason}`,
|
||||||
);
|
);
|
||||||
@@ -229,5 +235,5 @@ export const runMetadataBackup = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
log("Metadata backup complete.");
|
log("Metadata backup complete.");
|
||||||
return { failedMLBatches };
|
return { filesWithMLDataError: mlDataErrors.size };
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-24
@@ -28,43 +28,52 @@ interface RawRemoteFileData {
|
|||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt one entry with its file key and gunzip the JSON payload. Returns
|
// Decrypt one entry with its file key and gunzip the JSON payload. Throws why
|
||||||
// undefined when the key is unknown or the entry does not decrypt/parse, so one
|
// when the entry does not decrypt, gunzip or parse, or parses to something
|
||||||
// corrupt file never fails a whole batch.
|
// other than a JSON object, such as `null`, a number or an array.
|
||||||
const decodeEntry = (
|
const decodeEntry = (entry: RawRemoteFileData, key: Uint8Array): MLData => {
|
||||||
entry: RawRemoteFileData,
|
const decrypted = decryptBlob(
|
||||||
key: Uint8Array | undefined,
|
fromBase64(entry.encryptedData),
|
||||||
): MLData | undefined => {
|
fromBase64(entry.decryptionHeader),
|
||||||
if (!key) return undefined;
|
key,
|
||||||
try {
|
);
|
||||||
const decrypted = decryptBlob(
|
const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8");
|
||||||
fromBase64(entry.encryptedData),
|
const payload: unknown = JSON.parse(json);
|
||||||
fromBase64(entry.decryptionHeader),
|
if (
|
||||||
key,
|
typeof payload !== "object" ||
|
||||||
);
|
payload === null ||
|
||||||
const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8");
|
Array.isArray(payload)
|
||||||
return JSON.parse(json) as MLData;
|
) {
|
||||||
} catch {
|
throw new Error("not a JSON object");
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
return payload as MLData;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch ML data for up to `MLDATA_BATCH_SIZE` ids in a single request. This is
|
// Fetch ML data for up to `MLDATA_BATCH_SIZE` ids in a single request. This is
|
||||||
// the unit the request pools schedule; callers with more ids split them into
|
// the unit the request pools schedule; callers with more ids split them into
|
||||||
// batches and submit each batch to the pool.
|
// batches and submit each batch to the pool. Returns the payload of each file
|
||||||
|
// whose entry decodes and, in `errors`, why not for each file whose entry does
|
||||||
|
// not, so one corrupt file never fails a whole batch. An entry for a file
|
||||||
|
// whose key is not known, which quak did not ask for, is skipped.
|
||||||
export const fetchMLDataBatch = async (
|
export const fetchMLDataBatch = async (
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
fileIDs: number[],
|
fileIDs: number[],
|
||||||
fileKeys: Map<number, Uint8Array>,
|
fileKeys: Map<number, Uint8Array>,
|
||||||
): Promise<Map<number, MLData>> => {
|
): Promise<{ payloads: Map<number, MLData>; errors: Map<number, unknown> }> => {
|
||||||
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
|
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
|
||||||
"/files/data/fetch",
|
"/files/data/fetch",
|
||||||
{ type: "mldata", fileIDs },
|
{ type: "mldata", fileIDs },
|
||||||
);
|
);
|
||||||
const result = new Map<number, MLData>();
|
const payloads = new Map<number, MLData>();
|
||||||
|
const errors = new Map<number, unknown>();
|
||||||
for (const entry of data ?? []) {
|
for (const entry of data ?? []) {
|
||||||
const payload = decodeEntry(entry, fileKeys.get(entry.fileID));
|
const key = fileKeys.get(entry.fileID);
|
||||||
if (payload) result.set(entry.fileID, payload);
|
if (!key) continue;
|
||||||
|
try {
|
||||||
|
payloads.set(entry.fileID, decodeEntry(entry, key));
|
||||||
|
} catch (err) {
|
||||||
|
errors.set(entry.fileID, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return { payloads, errors };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* data this file encrypts with libsodium, as Ente's clients encrypt it. So
|
* data this file encrypts with libsodium, as Ente's clients encrypt it. So
|
||||||
* quak's own code fetches each original, decrypts it, checks it against the
|
* quak's own code fetches each original, decrypts it, checks it against the
|
||||||
* hash its uploader recorded and unpacks a live photo, and quak's own retries
|
* hash its uploader recorded and unpacks a live photo, and quak's own retries
|
||||||
* repeat a failed request. Two tests run `lib.backup()` instead, as a program
|
* repeat a failed request. Three tests run `lib.backup()` instead, as a program
|
||||||
* using quak as a library does.
|
* using quak as a library does.
|
||||||
*
|
*
|
||||||
* The account, user 42, has three albums:
|
* The account, user 42, has three albums:
|
||||||
@@ -877,6 +877,133 @@ describe("quak backup", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The server sends beach.jpg's ML data encrypted under a key that is not
|
||||||
|
// the file's, so quak cannot decrypt it. The file's JSON has the reason
|
||||||
|
// in place of the ML data, and the file goes into `failures.json` with
|
||||||
|
// it, so the exit code is 1. The other two files have no ML data, which
|
||||||
|
// is no failure.
|
||||||
|
it("records a file whose ML data does not decrypt in its JSON and in failures.json, and exits 1", async () => {
|
||||||
|
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const mlData = encryptStreamBlob(
|
||||||
|
gzipSync(JSON.stringify(ML_DATA)),
|
||||||
|
otherKey,
|
||||||
|
);
|
||||||
|
const fetch: typeof globalThis.fetch = async (input, init) => {
|
||||||
|
if (new URL(String(input)).pathname !== "/files/data/fetch") {
|
||||||
|
return server(input, init);
|
||||||
|
}
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
fileID: BEACH.id,
|
||||||
|
encryptedData: toBase64(mlData.ciphertext),
|
||||||
|
decryptionHeader: toBase64(mlData.header),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return new Response(JSON.stringify({ data }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const dir = mkdtempSync(join(root, "backup-"));
|
||||||
|
|
||||||
|
const { code, stderr } = await backup(dir, {}, fetch);
|
||||||
|
|
||||||
|
expect(code).toBe(1);
|
||||||
|
const reason = "secretstream chunk authentication failed";
|
||||||
|
const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json"));
|
||||||
|
expect(beachJSON).not.toHaveProperty("mlData");
|
||||||
|
expect(beachJSON.mlDataError).toBe(reason);
|
||||||
|
const failures = readJSON(join(dir, "failures.json"));
|
||||||
|
expect(Object.keys(failures.files)).toEqual(["100"]);
|
||||||
|
expect(failures.files["100"]).toMatchObject({
|
||||||
|
fileID: 100,
|
||||||
|
title: "beach.jpg",
|
||||||
|
error: `ML data: ${reason}`,
|
||||||
|
});
|
||||||
|
expect(stderr).toContain(`beach.jpg (id 100): ML data: ${reason}\n`);
|
||||||
|
for (const fileID of [200, 300]) {
|
||||||
|
const json = readJSON(join(dir, DAY, `2024-03-01.${fileID}.json`));
|
||||||
|
expect(json).not.toHaveProperty("mlData");
|
||||||
|
expect(json).not.toHaveProperty("mlDataError");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A program using quak as a library backs the account up, and another ML
|
||||||
|
// data fetch, as one the library's refresh starts, fails while the backup
|
||||||
|
// writes the JSON files. The backup's own fetch got beach.jpg's ML data
|
||||||
|
// under a key that is not the file's; the fetch that fails leaves that
|
||||||
|
// reason in place, so beach.jpg's JSON and `failures.json` still give it.
|
||||||
|
it("keeps the reason in beach.jpg's JSON and in failures.json when an ML data fetch fails while lib.backup() writes the JSON files", async () => {
|
||||||
|
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const mlData = encryptStreamBlob(
|
||||||
|
gzipSync(JSON.stringify(ML_DATA)),
|
||||||
|
otherKey,
|
||||||
|
);
|
||||||
|
let mlDataFetchFails = false;
|
||||||
|
const fetch: typeof globalThis.fetch = async (input, init) => {
|
||||||
|
if (new URL(String(input)).pathname !== "/files/data/fetch") {
|
||||||
|
return server(input, init);
|
||||||
|
}
|
||||||
|
if (mlDataFetchFails) throw new Error("the connection was reset");
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
fileID: BEACH.id,
|
||||||
|
encryptedData: toBase64(mlData.ciphertext),
|
||||||
|
decryptionHeader: toBase64(mlData.header),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return new Response(JSON.stringify({ data }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const dir = mkdtempSync(join(root, "backup-"));
|
||||||
|
const lib = await Library.open({
|
||||||
|
client: loadSession(join(sessionDir, "session.json"), {
|
||||||
|
fetch,
|
||||||
|
retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} },
|
||||||
|
})!,
|
||||||
|
cacheDirectory: `${dir}-cache`,
|
||||||
|
downloadDirectory: dir,
|
||||||
|
refreshIntervalSeconds: Infinity,
|
||||||
|
precacheThumbnails: false,
|
||||||
|
precacheOriginals: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// The backup reads a file's cached ML data just before it writes
|
||||||
|
// that file's JSON. The first time, a refresh starts an ML data
|
||||||
|
// fetch whose request fails, and the backup goes on once that
|
||||||
|
// fetch has failed.
|
||||||
|
const forFile = lib.mldata.forFile.bind(lib.mldata);
|
||||||
|
vi.spyOn(lib.mldata, "forFile").mockImplementationOnce(
|
||||||
|
async (args) => {
|
||||||
|
mlDataFetchFails = true;
|
||||||
|
await lib.fresh();
|
||||||
|
await vi.waitFor(
|
||||||
|
() => expect(lib.status().lastMLError).toBeDefined(),
|
||||||
|
{ timeout: 2000, interval: 5 },
|
||||||
|
);
|
||||||
|
return forFile(args);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await lib.backup();
|
||||||
|
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
const reason = "secretstream chunk authentication failed";
|
||||||
|
const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json"));
|
||||||
|
expect(beachJSON).not.toHaveProperty("mlData");
|
||||||
|
expect(beachJSON.mlDataError).toBe(reason);
|
||||||
|
const failures = readJSON(join(dir, "failures.json"));
|
||||||
|
expect(Object.keys(failures.files)).toEqual(["100"]);
|
||||||
|
expect(failures.files["100"]).toMatchObject({
|
||||||
|
fileID: 100,
|
||||||
|
title: "beach.jpg",
|
||||||
|
error: `ML data: ${reason}`,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Something changed beach.jpg on disk after it was backed up. A run with
|
// Something changed beach.jpg on disk after it was backed up. A run with
|
||||||
// --verify hashes every stored original, finds that one no longer matches
|
// --verify hashes every stored original, finds that one no longer matches
|
||||||
// the hash Ente recorded, says so, and downloads it again; it put back the
|
// the hash Ente recorded, says so, and downloads it again; it put back the
|
||||||
|
|||||||
+91
-26
@@ -191,9 +191,12 @@ class HostileClient extends MockClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A content source that writes byte buffers of the expected length and can be
|
// A content source that writes byte buffers of the expected length and can be
|
||||||
// told to fail one fileID's original, to exercise per-file resilience.
|
// told to fail one fileID's original, as a server error or as a 404, or to
|
||||||
|
// write other bytes for it, to exercise per-file resilience.
|
||||||
interface StubSource extends ContentSource {
|
interface StubSource extends ContentSource {
|
||||||
failID?: number;
|
failID?: number;
|
||||||
|
notFoundID?: number;
|
||||||
|
otherBytesID?: number;
|
||||||
failThumbID?: number;
|
failThumbID?: number;
|
||||||
originalCalls: number;
|
originalCalls: number;
|
||||||
}
|
}
|
||||||
@@ -204,8 +207,10 @@ const stubSource = (): StubSource => {
|
|||||||
original: async ({ file: f, destination }) => {
|
original: async ({ file: f, destination }) => {
|
||||||
s.originalCalls++;
|
s.originalCalls++;
|
||||||
if (s.failID === f.id) throw new Error("HTTP 500 from server");
|
if (s.failID === f.id) throw new Error("HTTP 500 from server");
|
||||||
|
if (s.notFoundID === f.id) throw new Error("HTTP 404 from server");
|
||||||
const size = SIZE_BY_ID[f.id] ?? 10;
|
const size = SIZE_BY_ID[f.id] ?? 10;
|
||||||
writeFileSync(destination, Buffer.alloc(size));
|
const fill = s.otherBytesID === f.id ? 2 : 0;
|
||||||
|
writeFileSync(destination, Buffer.alloc(size, fill));
|
||||||
return { bytesWritten: size };
|
return { bytesWritten: size };
|
||||||
},
|
},
|
||||||
thumbnail: async ({ file: f, destination }) => {
|
thumbnail: async ({ file: f, destination }) => {
|
||||||
@@ -696,17 +701,26 @@ describe("lib.backup", () => {
|
|||||||
// 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-2026-03-01.100.jpg-${exitedPID}-abc123.tmp`;
|
||||||
|
// A live photo's image and video downloaded to replace a stored one,
|
||||||
|
// before they were renamed into place.
|
||||||
|
const replacement = [
|
||||||
|
`.quak-${exitedPID}-abc789.heic`,
|
||||||
|
`.quak-${exitedPID}-abc789.mov`,
|
||||||
|
];
|
||||||
// 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-2026-03-01.101.jpg-${process.pid}-def456.tmp`;
|
||||||
writeFileSync(join(day, leftover), "partial");
|
for (const name of [leftover, ...replacement, inProgress]) {
|
||||||
writeFileSync(join(day, inProgress), "partial");
|
writeFileSync(join(day, name), "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(day);
|
||||||
expect(names).not.toContain(leftover);
|
expect(names).not.toContain(leftover);
|
||||||
|
expect(names).not.toContain(replacement[0]);
|
||||||
|
expect(names).not.toContain(replacement[1]);
|
||||||
expect(names).toContain(inProgress);
|
expect(names).toContain(inProgress);
|
||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
@@ -938,13 +952,14 @@ const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } };
|
|||||||
|
|
||||||
class MLClient extends MockClient {
|
class MLClient extends MockClient {
|
||||||
mlError?: string;
|
mlError?: string;
|
||||||
async fetchMLData(args: {
|
async fetchMLData(args: { fileIDs: number[] }): Promise<{
|
||||||
fileIDs: number[];
|
payloads: Map<number, MLData>;
|
||||||
}): Promise<Map<number, MLData>> {
|
errors: Map<number, unknown>;
|
||||||
|
}> {
|
||||||
if (this.mlError) throw new Error(this.mlError);
|
if (this.mlError) throw new Error(this.mlError);
|
||||||
const result = new Map<number, MLData>();
|
const payloads = new Map<number, MLData>();
|
||||||
if (args.fileIDs.includes(100)) result.set(100, ML_PAYLOAD);
|
if (args.fileIDs.includes(100)) payloads.set(100, ML_PAYLOAD);
|
||||||
return result;
|
return { payloads, errors: new Map() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1294,7 +1309,8 @@ describe("image metadata in each file's JSON", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// With `verify`, each original already stored is hashed, and one that does not
|
// With `verify`, each original already stored is hashed, and one that does not
|
||||||
// match the content hash its metadata records is downloaded again.
|
// match the content hash its metadata records is downloaded again, and replaced
|
||||||
|
// only by a download that matches.
|
||||||
describe("backup with verify", () => {
|
describe("backup with verify", () => {
|
||||||
// MockClient's files, each recording the hash of what `stubSource` writes
|
// MockClient's files, each recording the hash of what `stubSource` writes
|
||||||
// for it, except diagram.png (200), which records none.
|
// for it, except diagram.png (200), which records none.
|
||||||
@@ -1337,6 +1353,12 @@ describe("backup with verify", () => {
|
|||||||
const good = Buffer.alloc(SIZE_BY_ID[100]!);
|
const good = Buffer.alloc(SIZE_BY_ID[100]!);
|
||||||
const corrupt = Buffer.alloc(SIZE_BY_ID[100]!, 1);
|
const corrupt = Buffer.alloc(SIZE_BY_ID[100]!, 1);
|
||||||
|
|
||||||
|
// The temporary files left in the date folder of the backup in `outDir`.
|
||||||
|
const tempFiles = (outDir: string): string[] =>
|
||||||
|
readdirSync(join(outDir, DAY)).filter((name) =>
|
||||||
|
name.startsWith(".quak-"),
|
||||||
|
);
|
||||||
|
|
||||||
it("leaves an original that matches its hash, and one with no hash, as they are", async () => {
|
it("leaves an original that matches its hash, and one with no hash, as they are", async () => {
|
||||||
const { lib, source, outDir } = await backedUp();
|
const { lib, source, outDir } = await backedUp();
|
||||||
const calls = source.originalCalls;
|
const calls = source.originalCalls;
|
||||||
@@ -1381,13 +1403,16 @@ describe("backup with verify", () => {
|
|||||||
expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([
|
expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([
|
||||||
"MISMATCH original beach.jpg (100): its bytes do not match its content hash",
|
"MISMATCH original beach.jpg (100): its bytes do not match its content hash",
|
||||||
]);
|
]);
|
||||||
|
expect(tempFiles(outDir)).toEqual([]);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("records a failed download in failures.json, with the original removed", async () => {
|
// A copy whose hash does not match is still mostly the photo, so the
|
||||||
|
// backup keeps it until it holds a better one.
|
||||||
|
it("keeps an original that does not match its hash when its download answers 404", async () => {
|
||||||
const { lib, source, outDir } = await backedUp();
|
const { lib, source, outDir } = await backedUp();
|
||||||
writeFileSync(saved(outDir, "100.jpg"), corrupt);
|
writeFileSync(saved(outDir, "100.jpg"), corrupt);
|
||||||
source.failID = 100;
|
source.notFoundID = 100;
|
||||||
|
|
||||||
const result = await lib.backup({
|
const result = await lib.backup({
|
||||||
downloadDirectory: outDir,
|
downloadDirectory: outDir,
|
||||||
@@ -1400,18 +1425,18 @@ describe("backup with verify", () => {
|
|||||||
failed: 1,
|
failed: 1,
|
||||||
});
|
});
|
||||||
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
|
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
|
||||||
expect(existsSync(saved(outDir, "100.jpg"))).toBe(false);
|
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
|
||||||
|
expect(
|
||||||
|
readlinkSync(join(outDir, "collections", "Vacation", "beach.jpg")),
|
||||||
|
).toBe(linkTo("100.jpg"));
|
||||||
|
expect(tempFiles(outDir)).toEqual([]);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("records in failures.json an original the cache puts back with the same bad bytes", async () => {
|
it("keeps an original that does not match its hash when its download does not match either", async () => {
|
||||||
const lib = await openLibrary(stubSource(), new HashedClient());
|
const { lib, source, outDir } = await backedUp();
|
||||||
const outDir = join(root, "backup");
|
writeFileSync(saved(outDir, "100.jpg"), corrupt);
|
||||||
// The cache holds a bad copy, and a backup copies an original the
|
source.otherBytesID = 100;
|
||||||
// cache holds to its save path.
|
|
||||||
const cached = await lib.photos.byID({ fileID: 100 })!.original();
|
|
||||||
writeFileSync(cached.path, corrupt);
|
|
||||||
await lib.backup({ downloadDirectory: outDir });
|
|
||||||
|
|
||||||
const result = await lib.backup({
|
const result = await lib.backup({
|
||||||
downloadDirectory: outDir,
|
downloadDirectory: outDir,
|
||||||
@@ -1424,10 +1449,39 @@ describe("backup with verify", () => {
|
|||||||
failed: 1,
|
failed: 1,
|
||||||
});
|
});
|
||||||
expect(result.errors.map((e) => e.error)).toEqual([
|
expect(result.errors.map((e) => e.error)).toEqual([
|
||||||
"the original put back does not match its content hash either",
|
"the downloaded original does not match its content hash either",
|
||||||
]);
|
]);
|
||||||
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
|
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
|
||||||
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
|
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
|
||||||
|
expect(tempFiles(outDir)).toEqual([]);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The cache can hold the same bad bytes, as a copy from it is not checked
|
||||||
|
// as a download is, so the replacement is always downloaded.
|
||||||
|
it("downloads the replacement even when the cache holds the original", async () => {
|
||||||
|
const source = stubSource();
|
||||||
|
const lib = await openLibrary(source, new HashedClient());
|
||||||
|
const outDir = join(root, "backup");
|
||||||
|
// The cache holds a bad copy, and a backup copies an original the
|
||||||
|
// cache holds to its save path.
|
||||||
|
const cached = await lib.photos.byID({ fileID: 100 })!.original();
|
||||||
|
writeFileSync(cached.path, corrupt);
|
||||||
|
await lib.backup({ downloadDirectory: outDir });
|
||||||
|
const calls = source.originalCalls;
|
||||||
|
|
||||||
|
const result = await lib.backup({
|
||||||
|
downloadDirectory: outDir,
|
||||||
|
verify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
downloaded: 1,
|
||||||
|
mismatched: 1,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
expect(source.originalCalls).toBe(calls + 1);
|
||||||
|
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(good);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1511,12 +1565,16 @@ describe("backup album folders", () => {
|
|||||||
writeFileSync(path, `original ${fileID}`);
|
writeFileSync(path, `original ${fileID}`);
|
||||||
return { path };
|
return { path };
|
||||||
},
|
},
|
||||||
|
fetchOriginal: async () => {
|
||||||
|
throw new Error("no verify in this stand-in");
|
||||||
|
},
|
||||||
thumbnail: async () => {
|
thumbnail: async () => {
|
||||||
throw new Error("no thumbnails in this stand-in");
|
throw new Error("no thumbnails in this stand-in");
|
||||||
},
|
},
|
||||||
contentConcurrency: 5,
|
contentConcurrency: 5,
|
||||||
fetchMLData: async () => {},
|
fetchMLData: async () => {},
|
||||||
mlData: async () => undefined,
|
mlData: async () => undefined,
|
||||||
|
mlDataError: () => undefined,
|
||||||
whoami: () => ({ email: "backup@example.com", userID: USER_ID }),
|
whoami: () => ({ email: "backup@example.com", userID: USER_ID }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2244,11 +2302,12 @@ describe("backup of live photos", () => {
|
|||||||
Buffer.from(IMAGE),
|
Buffer.from(IMAGE),
|
||||||
);
|
);
|
||||||
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
|
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
|
||||||
|
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
|
||||||
expect(tree(outDir)).toEqual(linked);
|
expect(tree(outDir)).toEqual(linked);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes both files of a live photo that does not match its hash when downloading it again fails", async () => {
|
it("keeps both files of a live photo that does not match its hash when downloading it again fails", 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"),
|
||||||
);
|
);
|
||||||
@@ -2266,8 +2325,14 @@ describe("backup of live photos", () => {
|
|||||||
|
|
||||||
expect(result).toMatchObject({ mismatched: 1, failed: 1 });
|
expect(result).toMatchObject({ mismatched: 1, failed: 1 });
|
||||||
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
|
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
|
||||||
expect(existsSync(saved(outDir, "500.heic"))).toBe(false);
|
expect(readFileSync(saved(outDir, "500.heic"))).toEqual(
|
||||||
expect(existsSync(saved(outDir, "500.mov"))).toBe(false);
|
Buffer.from(IMAGE),
|
||||||
|
);
|
||||||
|
expect(readFileSync(saved(outDir, "500.mov"), "utf-8")).toBe(
|
||||||
|
"another few seconds of video",
|
||||||
|
);
|
||||||
|
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
|
||||||
|
expect(tree(outDir)).toEqual(linked);
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+203
-12
@@ -53,11 +53,21 @@ import {
|
|||||||
} from "../../src/cli-commands.js";
|
} from "../../src/cli-commands.js";
|
||||||
import { run } from "../../src/cli-run.js";
|
import { run } from "../../src/cli-run.js";
|
||||||
import { loadSession } from "../../src/cli-session.js";
|
import { loadSession } from "../../src/cli-session.js";
|
||||||
import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
|
import {
|
||||||
|
Client,
|
||||||
|
type ClientSnapshot,
|
||||||
|
type LoginOptions,
|
||||||
|
} from "../../src/client.js";
|
||||||
import { savePath, type ContentSource } from "../../src/library/content.js";
|
import { savePath, 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 { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
|
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
|
||||||
import { defaultCacheDirectory } from "../../src/library/index.js";
|
import { defaultCacheDirectory } from "../../src/library/index.js";
|
||||||
|
import {
|
||||||
|
buildAccount,
|
||||||
|
buildMetaFetch,
|
||||||
|
encryptSecretbox,
|
||||||
|
encryptStreamBlob,
|
||||||
|
} from "../ente-server.js";
|
||||||
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
||||||
import {
|
import {
|
||||||
asLivePhoto,
|
asLivePhoto,
|
||||||
@@ -171,6 +181,12 @@ const fakeClient = (
|
|||||||
return fake as unknown as Client;
|
return fake as unknown as Client;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// What a read command prints on stderr while the refresh it waits for fetches
|
||||||
|
// the files of each of the fake's two albums, on an empty cache.
|
||||||
|
const FETCHED_BOTH_ALBUMS =
|
||||||
|
"Fetched the files of 1 of 2 collections\n" +
|
||||||
|
"Fetched the files of 2 of 2 collections\n";
|
||||||
|
|
||||||
// Collects everything written to it.
|
// Collects everything written to it.
|
||||||
class Output {
|
class Output {
|
||||||
text = "";
|
text = "";
|
||||||
@@ -510,7 +526,9 @@ describe("files", () => {
|
|||||||
|
|
||||||
it("exits 1 for an unknown collection", async () => {
|
it("exits 1 for an unknown collection", async () => {
|
||||||
expect(await filesCommand(context(), { collection: "9" })).toBe(1);
|
expect(await filesCommand(context(), { collection: "9" })).toBe(1);
|
||||||
expect(stderr.text).toBe("Collection 9 not found\n");
|
expect(stderr.text).toBe(
|
||||||
|
FETCHED_BOTH_ALBUMS + "Collection 9 not found\n",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -519,27 +537,27 @@ describe("get and get-thumb", () => {
|
|||||||
const out = join(root, "diagram.png");
|
const out = join(root, "diagram.png");
|
||||||
expect(await getCommand(context(), "200", { out })).toBe(0);
|
expect(await getCommand(context(), "200", { out })).toBe(0);
|
||||||
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
|
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
|
||||||
expect(stderr.text).toBe(`7 bytes -> ${out}\n`);
|
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `7 bytes -> ${out}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("get-thumb finds a file in any album without --collection", async () => {
|
it("get-thumb finds a file in any album without --collection", async () => {
|
||||||
const out = join(root, "thumb.jpg");
|
const out = join(root, "thumb.jpg");
|
||||||
expect(await getThumbCommand(context(), "200", { out })).toBe(0);
|
expect(await getThumbCommand(context(), "200", { out })).toBe(0);
|
||||||
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
|
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
|
||||||
expect(stderr.text).toBe(`3 bytes -> ${out}\n`);
|
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `3 bytes -> ${out}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("get exits 1 when no album has the file", async () => {
|
it("get exits 1 when no album has the file", async () => {
|
||||||
const out = join(root, "x");
|
const out = join(root, "x");
|
||||||
expect(await getCommand(context(), "999", { out })).toBe(1);
|
expect(await getCommand(context(), "999", { out })).toBe(1);
|
||||||
expect(stderr.text).toBe("File 999 not found\n");
|
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n");
|
||||||
expect(existsSync(out)).toBe(false);
|
expect(existsSync(out)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("get-thumb exits 1 when no album has the file", async () => {
|
it("get-thumb exits 1 when no album has the file", async () => {
|
||||||
const out = join(root, "x");
|
const out = join(root, "x");
|
||||||
expect(await getThumbCommand(context(), "999", { out })).toBe(1);
|
expect(await getThumbCommand(context(), "999", { out })).toBe(1);
|
||||||
expect(stderr.text).toBe("File 999 not found\n");
|
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n");
|
||||||
expect(existsSync(out)).toBe(false);
|
expect(existsSync(out)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -549,6 +567,9 @@ describe("get and get-thumb", () => {
|
|||||||
// one live photo, 300, downloaded through the real download layer
|
// one live photo, 300, downloaded through the real download layer
|
||||||
// (test/live-photo.ts).
|
// (test/live-photo.ts).
|
||||||
describe("a live photo", () => {
|
describe("a live photo", () => {
|
||||||
|
// What `get` prints on stderr while its refresh fetches Vacation's files.
|
||||||
|
const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n";
|
||||||
|
|
||||||
const livePhotoClient = async (image = IMAGE): Promise<Client> => {
|
const livePhotoClient = async (image = IMAGE): Promise<Client> => {
|
||||||
const { file: live, body } = await asLivePhoto(
|
const { file: live, body } = await asLivePhoto(
|
||||||
file(300, 1, "IMG_0300.HEIC"),
|
file(300, 1, "IMG_0300.HEIC"),
|
||||||
@@ -593,7 +614,8 @@ describe("a live photo", () => {
|
|||||||
Buffer.from(VIDEO),
|
Buffer.from(VIDEO),
|
||||||
);
|
);
|
||||||
expect(stderr.text).toBe(
|
expect(stderr.text).toBe(
|
||||||
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
|
FETCHED_ONE_ALBUM +
|
||||||
|
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
|
||||||
`${VIDEO.length} bytes -> IMG_0300.mov\n`,
|
`${VIDEO.length} bytes -> IMG_0300.mov\n`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -609,7 +631,8 @@ describe("a live photo", () => {
|
|||||||
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
|
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
|
||||||
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
|
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
|
||||||
expect(stderr.text).toBe(
|
expect(stderr.text).toBe(
|
||||||
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
|
FETCHED_ONE_ALBUM +
|
||||||
|
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -623,7 +646,8 @@ describe("a live photo", () => {
|
|||||||
expect(existsSync(out)).toBe(false);
|
expect(existsSync(out)).toBe(false);
|
||||||
expect(existsSync(join(root, "photo.mov"))).toBe(false);
|
expect(existsSync(join(root, "photo.mov"))).toBe(false);
|
||||||
expect(stderr.text).toBe(
|
expect(stderr.text).toBe(
|
||||||
`File 300 is a live photo, and its video would also be written to ${out}\n`,
|
FETCHED_ONE_ALBUM +
|
||||||
|
`File 300 is a live photo, and its video would also be written to ${out}\n`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1103,11 +1127,12 @@ describe("backup-metadata --exif", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each test first runs `collections` so the cache holds the account as it was,
|
// Each test first runs `files` so the cache holds the account as it was, then
|
||||||
// then changes the server under it.
|
// changes the server under it. (`collections` would not do: it reads the
|
||||||
|
// collection list alone and stores nothing.)
|
||||||
describe("backup-metadata and the thumbnail helpers refresh first", () => {
|
describe("backup-metadata and the thumbnail helpers refresh first", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
expect(await collectionsCommand(context(), {})).toBe(0);
|
expect(await filesCommand(context(), { collection: "1" })).toBe(0);
|
||||||
stdout.text = "";
|
stdout.text = "";
|
||||||
stderr.text = "";
|
stderr.text = "";
|
||||||
});
|
});
|
||||||
@@ -1292,3 +1317,169 @@ describe("an ID given on the command line", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An account with many albums (issue #210). On an empty cache `collections`
|
||||||
|
// used to wait for a refresh that fetched and decrypted the files of every
|
||||||
|
// album, one album after another, before it printed anything, so on such an
|
||||||
|
// account it seemed to hang. The account here has 40 albums of one photo each,
|
||||||
|
// encrypted as Ente's clients encrypt them, on the fake Ente server of
|
||||||
|
// `test/ente-server.ts`, which also records the path of each request quak
|
||||||
|
// sends. The session is the one logging in to that server saves.
|
||||||
|
describe("an account with many albums", () => {
|
||||||
|
const ALBUMS = 40;
|
||||||
|
const PASSWORD = "many-albums";
|
||||||
|
const paths: string[] = [];
|
||||||
|
let server: typeof globalThis.fetch;
|
||||||
|
let snapshot: ClientSnapshot;
|
||||||
|
|
||||||
|
// Album n is named `Album n` and holds one photo, n * 100, titled
|
||||||
|
// `photo-n.jpg`.
|
||||||
|
beforeAll(async () => {
|
||||||
|
await sodium.ready;
|
||||||
|
const account = await buildAccount(PASSWORD);
|
||||||
|
const collections: Record<string, unknown>[] = [];
|
||||||
|
const filesByCollection: Record<number, Record<string, unknown>[]> = {};
|
||||||
|
for (let id = 1; id <= ALBUMS; id++) {
|
||||||
|
const albumKey = sodium.crypto_secretbox_keygen();
|
||||||
|
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
|
||||||
|
const name = encryptSecretbox(
|
||||||
|
new TextEncoder().encode(`Album ${id}`),
|
||||||
|
albumKey,
|
||||||
|
);
|
||||||
|
collections.push({
|
||||||
|
id,
|
||||||
|
owner: { id: USER_ID },
|
||||||
|
encryptedKey: toBase64(albumKeyBox.ciphertext),
|
||||||
|
keyDecryptionNonce: toBase64(albumKeyBox.nonce),
|
||||||
|
encryptedName: toBase64(name.ciphertext),
|
||||||
|
nameDecryptionNonce: toBase64(name.nonce),
|
||||||
|
type: "album",
|
||||||
|
updationTime: 1,
|
||||||
|
});
|
||||||
|
const photoKey =
|
||||||
|
sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
|
||||||
|
const metadata = encryptStreamBlob(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
title: `photo-${id}.jpg`,
|
||||||
|
fileType: 0,
|
||||||
|
creationTime: 1000,
|
||||||
|
modificationTime: 1000,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
photoKey,
|
||||||
|
);
|
||||||
|
filesByCollection[id] = [
|
||||||
|
{
|
||||||
|
id: id * 100,
|
||||||
|
collectionID: id,
|
||||||
|
ownerID: USER_ID,
|
||||||
|
encryptedKey: toBase64(photoKeyBox.ciphertext),
|
||||||
|
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
|
||||||
|
metadata: {
|
||||||
|
encryptedData: toBase64(metadata.ciphertext),
|
||||||
|
decryptionHeader: toBase64(metadata.header),
|
||||||
|
},
|
||||||
|
// These tests fetch no original and no thumbnail.
|
||||||
|
file: { decryptionHeader: "" },
|
||||||
|
thumbnail: { decryptionHeader: "" },
|
||||||
|
updationTime: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const ente = buildMetaFetch({
|
||||||
|
verifier: account.verifier,
|
||||||
|
srpAttributes: account.srpAttributes,
|
||||||
|
keyAttributes: account.keyAttributes,
|
||||||
|
encryptedToken: account.encryptedToken,
|
||||||
|
collections,
|
||||||
|
filesByCollection,
|
||||||
|
encryptedMLData: {},
|
||||||
|
fileCiphertexts: {},
|
||||||
|
});
|
||||||
|
server = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url;
|
||||||
|
paths.push(new URL(url).pathname);
|
||||||
|
return ente(input, init);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
const client = await Client.login({
|
||||||
|
email: "many@example.com",
|
||||||
|
password: PASSWORD,
|
||||||
|
apiOptions: { fetch: server },
|
||||||
|
});
|
||||||
|
snapshot = client.toJSON();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
paths.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// A context whose saved session sends its requests to the fake server.
|
||||||
|
const accountContext = (): CliContext => {
|
||||||
|
const ctx = context();
|
||||||
|
saveSession(ctx.sessionDir, snapshot);
|
||||||
|
return {
|
||||||
|
...ctx,
|
||||||
|
loadSession: (path) => loadSession(path, { fetch: server }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it("collections lists every album from one request, fetching no album's files", async () => {
|
||||||
|
const ctx = accountContext();
|
||||||
|
expect(await collectionsCommand(ctx, {})).toBe(0);
|
||||||
|
|
||||||
|
let albums = "";
|
||||||
|
for (let id = 1; id <= ALBUMS; id++)
|
||||||
|
albums += `${id}\talbum\tAlbum ${id}\n`;
|
||||||
|
expect(stdout.text).toBe(albums);
|
||||||
|
expect(stderr.text).toBe("");
|
||||||
|
expect(paths).toEqual(["/collections/v2"]);
|
||||||
|
|
||||||
|
// It stored nothing that would make a refresh skip an album's files:
|
||||||
|
// `files` after it still finds the album's photo.
|
||||||
|
stdout.text = "";
|
||||||
|
expect(await filesCommand(ctx, { collection: "7" })).toBe(0);
|
||||||
|
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("files counts on stderr each album whose files its refresh fetched", async () => {
|
||||||
|
expect(await filesCommand(accountContext(), { collection: "7" })).toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
|
||||||
|
let progress = "";
|
||||||
|
for (let n = 1; n <= ALBUMS; n++) {
|
||||||
|
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
|
||||||
|
}
|
||||||
|
expect(stderr.text).toBe(progress);
|
||||||
|
});
|
||||||
|
|
||||||
|
// With `--json` the refresh's progress is not printed, as `backup` and
|
||||||
|
// the thumbnail helpers print none of theirs.
|
||||||
|
it("files --json prints no progress", async () => {
|
||||||
|
expect(
|
||||||
|
await filesCommand(accountContext(), {
|
||||||
|
collection: "7",
|
||||||
|
json: true,
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
|
||||||
|
expect(JSON.parse(stdout.text)).toEqual([
|
||||||
|
{
|
||||||
|
id: 700,
|
||||||
|
title: "photo-7.jpg",
|
||||||
|
fileType: "image",
|
||||||
|
creationTime: 1000,
|
||||||
|
collectionID: 7,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(stderr.text).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -489,15 +489,15 @@ describe("quak backup-metadata", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("quak backup-metadata when an ML data request fails", () => {
|
describe("quak backup-metadata when a file's ML data cannot be read", () => {
|
||||||
// Run the CLI command against the mock and return its exit code, stderr
|
// Run the CLI command against `fetch`, the mock unless given, and return
|
||||||
// and output directory.
|
// its exit code, stderr and output directory.
|
||||||
const runCommand = async (failMLDataFor?: number) => {
|
const runCommand = async (fetch = buildMetaFetch(mock)) => {
|
||||||
const client = await Client.login({
|
const client = await Client.login({
|
||||||
email: TEST_EMAIL,
|
email: TEST_EMAIL,
|
||||||
password: TEST_PASSWORD,
|
password: TEST_PASSWORD,
|
||||||
apiOptions: {
|
apiOptions: {
|
||||||
fetch: buildMetaFetch(mock, failMLDataFor),
|
fetch,
|
||||||
retry: { sleep: async () => {} },
|
retry: { sleep: async () => {} },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -518,7 +518,9 @@ describe("quak backup-metadata when an ML data request fails", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it("writes every file, marks the failed batch's files, and exits 1", async () => {
|
it("writes every file, marks the failed batch's files, and exits 1", async () => {
|
||||||
const { code, stderr, outDir } = await runCommand(200);
|
const { code, stderr, outDir } = await runCommand(
|
||||||
|
buildMetaFetch(mock, 200),
|
||||||
|
);
|
||||||
|
|
||||||
expect(code).toBe(1);
|
expect(code).toBe(1);
|
||||||
expect(stderr).toContain("ML data request for 1 file(s) failed");
|
expect(stderr).toContain("ML data request for 1 file(s) failed");
|
||||||
@@ -543,6 +545,49 @@ describe("quak backup-metadata when an ML data request fails", () => {
|
|||||||
expect(failed.mlDataError).toContain("500");
|
expect(failed.mlDataError).toContain("500");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The server sends ML data for diagram.png encrypted under a key that is
|
||||||
|
// not the file's, so quak cannot decrypt it.
|
||||||
|
it("writes the reason for ML data that does not decrypt, and exits 1", async () => {
|
||||||
|
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const { header, ciphertext } = encryptBlob(
|
||||||
|
new Uint8Array(gzipSync(Buffer.from("{}"))),
|
||||||
|
otherKey,
|
||||||
|
);
|
||||||
|
const { code, stderr, outDir } = await runCommand(
|
||||||
|
buildMetaFetch({
|
||||||
|
...mock,
|
||||||
|
encryptedMLData: {
|
||||||
|
...mock.encryptedMLData,
|
||||||
|
200: {
|
||||||
|
encryptedData: toBase64(ciphertext),
|
||||||
|
decryptionHeader: toBase64(header),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(code).toBe(1);
|
||||||
|
const reason = "secretstream chunk authentication failed";
|
||||||
|
expect(stderr).toContain(
|
||||||
|
`ML data of file 200 could not be read: ${reason}\n`,
|
||||||
|
);
|
||||||
|
const failed = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(outDir, "collections", "20-__Work", "200.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(failed.mlData).toBeUndefined();
|
||||||
|
expect(failed.mlDataError).toBe(reason);
|
||||||
|
const ok = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(outDir, "collections", "10-Vacation", "100.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
|
||||||
|
});
|
||||||
|
|
||||||
it("exits 0 when every ML data request succeeds", async () => {
|
it("exits 0 when every ML data request succeeds", async () => {
|
||||||
const { code } = await runCommand();
|
const { code } = await runCommand();
|
||||||
expect(code).toBe(0);
|
expect(code).toBe(0);
|
||||||
|
|||||||
+20
-11
@@ -3,10 +3,13 @@
|
|||||||
* issue #36, issue #52).
|
* issue #36, issue #52).
|
||||||
*
|
*
|
||||||
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for
|
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for
|
||||||
* current server state, not the local cache, so each helper forces a
|
* current server state, not the local cache. `files`, `get` and `get-thumb`
|
||||||
* `Library.fresh()` round-trip before it reads. The stand-in library below
|
* force a `Library.fresh()` round-trip before they read; the stand-in library
|
||||||
* serves nothing until `fresh()` has been awaited, so a helper that read
|
* below serves nothing until `fresh()` has been awaited, so a helper that read
|
||||||
* without refreshing would come back empty and fail here.
|
* without refreshing would come back empty and fail here. `collections` asks
|
||||||
|
* the library for the collection list alone (`freshCollections()`, issue
|
||||||
|
* #210), and must not wait for a refresh, which would fetch the files of every
|
||||||
|
* collection.
|
||||||
*
|
*
|
||||||
* `collections` and `files` also list in the library's enumeration order
|
* `collections` and `files` also list in the library's enumeration order
|
||||||
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
|
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
|
||||||
@@ -60,10 +63,13 @@ const file = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// A library that reveals its records only after `fresh()` has been awaited, and
|
// A library that reveals its records only after `fresh()` has been awaited, and
|
||||||
// serves them in the enumeration order it was given. `photos.byID` returns a
|
// serves them in the enumeration order it was given. `freshCollections()`
|
||||||
// stand-in `Photo` carrying just the fileID the helper passes through.
|
// answers the collection list in that order without a refresh. `photos.byID`
|
||||||
|
// returns a stand-in `Photo` carrying just the fileID the helper passes
|
||||||
|
// through.
|
||||||
class FakeLibrary implements FreshReadLibrary {
|
class FakeLibrary implements FreshReadLibrary {
|
||||||
freshCalls = 0;
|
freshCalls = 0;
|
||||||
|
freshCollectionsCalls = 0;
|
||||||
private refreshed = false;
|
private refreshed = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -77,12 +83,14 @@ class FakeLibrary implements FreshReadLibrary {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
listCollections(): Collection[] {
|
async freshCollections(): Promise<Collection[]> {
|
||||||
return this.refreshed ? this.collections : [];
|
this.freshCollectionsCalls++;
|
||||||
|
return this.collections;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCollection(id: number): Collection | undefined {
|
getCollection(id: number): Collection | undefined {
|
||||||
return this.listCollections().find((c) => c.id === id);
|
if (!this.refreshed) return undefined;
|
||||||
|
return this.collections.find((c) => c.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
listFiles(collectionID: number): EnteFile[] {
|
listFiles(collectionID: number): EnteFile[] {
|
||||||
@@ -106,7 +114,7 @@ class FakeLibrary implements FreshReadLibrary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
|
describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
|
||||||
it("freshCollections refreshes first, then lists in enumeration order", async () => {
|
it("freshCollections reads the collection list alone, in enumeration order", async () => {
|
||||||
// Enumeration order 2, 1, 3; a newest-first sort would be 3, 2, 1.
|
// Enumeration order 2, 1, 3; a newest-first sort would be 3, 2, 1.
|
||||||
const lib = new FakeLibrary(
|
const lib = new FakeLibrary(
|
||||||
[collection(2, 200), collection(1, 300), collection(3, 100)],
|
[collection(2, 200), collection(1, 300), collection(3, 100)],
|
||||||
@@ -114,7 +122,8 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
|
|||||||
);
|
);
|
||||||
const rows = await freshCollections(lib);
|
const rows = await freshCollections(lib);
|
||||||
|
|
||||||
expect(lib.freshCalls).toBe(1);
|
expect(lib.freshCollectionsCalls).toBe(1);
|
||||||
|
expect(lib.freshCalls).toBe(0);
|
||||||
expect(rows.map((c) => c.id)).toEqual([2, 1, 3]);
|
expect(rows.map((c) => c.id)).toEqual([2, 1, 3]);
|
||||||
// The projection's newest-first order is a different sequence, so this
|
// The projection's newest-first order is a different sequence, so this
|
||||||
// is not accidentally that order.
|
// is not accidentally that order.
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
* three.
|
* three.
|
||||||
* 3. A refresh that fails rejects the fresh read (currency was unavailable),
|
* 3. A refresh that fails rejects the fresh read (currency was unavailable),
|
||||||
* while the default reads stay silent and keep serving the last good copy.
|
* while the default reads stay silent and keep serving the last good copy.
|
||||||
|
* 4. `freshCollections()` reads the collection list alone, with one request
|
||||||
|
* and no collection's files, and stores nothing, so the next refresh still
|
||||||
|
* fetches the files of every collection (issue #210).
|
||||||
*
|
*
|
||||||
* The client is the same metadata-only mock the background-refresh tests use:
|
* The client is the same metadata-only mock the background-refresh tests use:
|
||||||
* no crypto, no network, scripted pages, and a record of each call. A long
|
* no crypto, no network, scripted pages, and a record of each call. A long
|
||||||
@@ -270,3 +273,70 @@ describe("Library.fresh", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Library.freshCollections", () => {
|
||||||
|
let dir: string;
|
||||||
|
let cacheDirectory: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "quak-fresh-collections-"));
|
||||||
|
cacheDirectory = join(dir, "cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// A refresh fetches a collection's files only when the collection is new
|
||||||
|
// or its updationTime has advanced past the stored copy's. Had the
|
||||||
|
// collection list been stored, the next refresh would take every
|
||||||
|
// collection for one it already holds the files of, and fetch none.
|
||||||
|
it("asks for the collection list alone and stores nothing", async () => {
|
||||||
|
const client = new MockClient();
|
||||||
|
const page = {
|
||||||
|
collections: [collection(2, 100), collection(1, 110)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 110,
|
||||||
|
};
|
||||||
|
client.collectionsQueue.push(page, page);
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1001, 1, 90)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 90,
|
||||||
|
});
|
||||||
|
client.filesFor(2, {
|
||||||
|
files: [file(2001, 2, 95)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 95,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshOnOpen: false,
|
||||||
|
refreshIntervalSeconds: SLOW_INTERVAL,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// One request, the whole list in the server's order, and no
|
||||||
|
// collection's files.
|
||||||
|
const collections = await lib.freshCollections();
|
||||||
|
expect(collections.map((c) => c.id)).toEqual([2, 1]);
|
||||||
|
expect(collections.map((c) => c.name)).toEqual([
|
||||||
|
"album-2",
|
||||||
|
"album-1",
|
||||||
|
]);
|
||||||
|
expect(client.collectionsSinceTimes).toEqual([0]);
|
||||||
|
expect(client.filesCalls).toEqual([]);
|
||||||
|
expect(lib.listCollections()).toEqual([]);
|
||||||
|
|
||||||
|
// The next refresh still asks from the beginning and fetches the
|
||||||
|
// files of both collections.
|
||||||
|
const reads = await lib.fresh();
|
||||||
|
expect(client.collectionsSinceTimes).toEqual([0, 0]);
|
||||||
|
expect(reads.photos.byID({ fileID: 1001 })?.fileID).toBe(1001);
|
||||||
|
expect(reads.photos.byID({ fileID: 2001 })?.fileID).toBe(2001);
|
||||||
|
} finally {
|
||||||
|
await lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
* opening.
|
* opening.
|
||||||
* 8. A save failure that leaves RAM ahead of disk keeps `status().lastError`
|
* 8. A save failure that leaves RAM ahead of disk keeps `status().lastError`
|
||||||
* set and keeps retrying the write; a later empty refresh does not clear it.
|
* set and keeps retrying the write; a later empty refresh does not clear it.
|
||||||
|
* 9. With `refreshOnOpen: false`, `open()` starts no refresh at all, and a
|
||||||
|
* refresh reports through `onProgress` each collection whose files it
|
||||||
|
* fetched.
|
||||||
*
|
*
|
||||||
* The client is a mock: no crypto, no network. It serves scripted pages and
|
* The client is a mock: no crypto, no network. It serves scripted pages and
|
||||||
* records the `sinceTime` each call carried so cursor threading is provable.
|
* records the `sinceTime` each call carried so cursor threading is provable.
|
||||||
@@ -581,6 +584,107 @@ describe("Library.open and background refresh", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A caller that asks for each refresh it needs, as the CLI's read commands
|
||||||
|
// do, opens without one: on an empty cache the refresh `open()` would
|
||||||
|
// await fetches the files of every collection, which `quak collections`
|
||||||
|
// never needs (issue #210).
|
||||||
|
it("starts no refresh as it opens when refreshOnOpen is false", async () => {
|
||||||
|
const client = new MockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 100)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshOnOpen: false,
|
||||||
|
refreshIntervalSeconds: Infinity,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(client.collectionsSinceTimes).toEqual([]);
|
||||||
|
expect(lib.listCollections()).toEqual([]);
|
||||||
|
expect(lib.status().lastRefreshAt).toBeUndefined();
|
||||||
|
|
||||||
|
// The refresh comes when the caller asks for it.
|
||||||
|
await lib.fresh();
|
||||||
|
expect(client.collectionsSinceTimes).toEqual([0]);
|
||||||
|
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
|
||||||
|
} finally {
|
||||||
|
await lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A refresh fetches the files of each collection that changed, one
|
||||||
|
// collection after another, and stores nothing until it has them all. On
|
||||||
|
// a large account that takes a while, so it reports each collection whose
|
||||||
|
// files it has fetched, and how many it will fetch in all (issue #210).
|
||||||
|
it("reports each collection whose files a refresh fetched", async () => {
|
||||||
|
const events: RefreshEvent[] = [];
|
||||||
|
const client = new MockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [
|
||||||
|
collection(1, 100),
|
||||||
|
collection(2, 100),
|
||||||
|
collection(3, 100),
|
||||||
|
],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshIntervalSeconds: Infinity,
|
||||||
|
onProgress: (e) => events.push(e),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ operation: "refresh", status: "started" },
|
||||||
|
{
|
||||||
|
operation: "refresh",
|
||||||
|
status: "progress",
|
||||||
|
fetched: 1,
|
||||||
|
total: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "refresh",
|
||||||
|
status: "progress",
|
||||||
|
fetched: 2,
|
||||||
|
total: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "refresh",
|
||||||
|
status: "progress",
|
||||||
|
fetched: 3,
|
||||||
|
total: 3,
|
||||||
|
},
|
||||||
|
{ operation: "refresh", status: "done" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only collection 2 changed since, so the next refresh fetches
|
||||||
|
// its files alone and counts one.
|
||||||
|
events.length = 0;
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(2, 200)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 200,
|
||||||
|
});
|
||||||
|
await lib.fresh();
|
||||||
|
expect(events.filter((e) => e.status === "progress")).toEqual([
|
||||||
|
{
|
||||||
|
operation: "refresh",
|
||||||
|
status: "progress",
|
||||||
|
fetched: 1,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a save failure visible until a save actually succeeds", async () => {
|
it("keeps a save failure visible until a save actually succeeds", async () => {
|
||||||
const saveSpy = vi
|
const saveSpy = vi
|
||||||
.spyOn(MetadataStore.prototype, "save")
|
.spyOn(MetadataStore.prototype, "save")
|
||||||
|
|||||||
+150
-9
@@ -12,9 +12,11 @@
|
|||||||
*
|
*
|
||||||
* 2. `Library` wiring: after each refresh the library fetches ML data through
|
* 2. `Library` wiring: after each refresh the library fetches ML data through
|
||||||
* the metadata pool for every known file not yet cached, is incremental on
|
* the metadata pool for every known file not yet cached, is incremental on
|
||||||
* later refreshes, and refetches a file whose `updationTime` advanced.
|
* later refreshes, and refetches a file whose `updationTime` advanced. A
|
||||||
* Opening a cache directory written by another account starts empty,
|
* file whose ML data did not decode is reported with the reason, which
|
||||||
* its ML data included (issue #104).
|
* stays listed until the next pass ends, and asked for again. Opening a
|
||||||
|
* cache directory written by another account starts empty, its ML data
|
||||||
|
* included (issue #104).
|
||||||
*
|
*
|
||||||
* Embedding values are chosen to be exactly representable as float32 so the
|
* Embedding values are chosen to be exactly representable as float32 so the
|
||||||
* round-trip through `clip.f32` compares equal.
|
* round-trip through `clip.f32` compares equal.
|
||||||
@@ -26,7 +28,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { MLDataStore } from "../../src/library/mldata.js";
|
import { MLDataStore } from "../../src/library/mldata.js";
|
||||||
import { Library } from "../../src/library/index.js";
|
import { Library, type RefreshEvent } from "../../src/library/index.js";
|
||||||
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
||||||
import type { MLData } from "../../src/mldata-fetch.js";
|
import type { MLData } from "../../src/mldata-fetch.js";
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
@@ -256,12 +258,14 @@ const file = (
|
|||||||
|
|
||||||
// A mock client that serves scripted collection/file pages and per-file ML
|
// A mock client that serves scripted collection/file pages and per-file ML
|
||||||
// payloads, recording every ML fetch request so incremental behaviour is
|
// payloads, recording every ML fetch request so incremental behaviour is
|
||||||
// provable.
|
// provable. A file in `mlErrorByFile` has ML data that did not decode, for
|
||||||
|
// the reason given there.
|
||||||
class MLMockClient {
|
class MLMockClient {
|
||||||
userID = USER_ID;
|
userID = USER_ID;
|
||||||
collectionsQueue: CollectionsPage[] = [];
|
collectionsQueue: CollectionsPage[] = [];
|
||||||
filesByCollection = new Map<number, FilesPage[]>();
|
filesByCollection = new Map<number, FilesPage[]>();
|
||||||
mlByFile = new Map<number, MLData>();
|
mlByFile = new Map<number, MLData>();
|
||||||
|
mlErrorByFile = new Map<number, Error>();
|
||||||
mlFetchCalls: number[][] = [];
|
mlFetchCalls: number[][] = [];
|
||||||
|
|
||||||
whoami(): { email: string; userID: number } {
|
whoami(): { email: string; userID: number } {
|
||||||
@@ -298,14 +302,20 @@ class MLMockClient {
|
|||||||
async fetchMLData(args: {
|
async fetchMLData(args: {
|
||||||
fileIDs: number[];
|
fileIDs: number[];
|
||||||
fileKeys: Map<number, Uint8Array>;
|
fileKeys: Map<number, Uint8Array>;
|
||||||
}): Promise<Map<number, MLData>> {
|
}): Promise<{
|
||||||
|
payloads: Map<number, MLData>;
|
||||||
|
errors: Map<number, unknown>;
|
||||||
|
}> {
|
||||||
this.mlFetchCalls.push([...args.fileIDs]);
|
this.mlFetchCalls.push([...args.fileIDs]);
|
||||||
const result = new Map<number, MLData>();
|
const payloads = new Map<number, MLData>();
|
||||||
|
const errors = new Map<number, unknown>();
|
||||||
for (const id of args.fileIDs) {
|
for (const id of args.fileIDs) {
|
||||||
const p = this.mlByFile.get(id);
|
const p = this.mlByFile.get(id);
|
||||||
if (p) result.set(id, p);
|
if (p) payloads.set(id, p);
|
||||||
|
const error = this.mlErrorByFile.get(id);
|
||||||
|
if (error) errors.set(id, error);
|
||||||
}
|
}
|
||||||
return result;
|
return { payloads, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
filesFor(collectionID: number, ...pages: FilesPage[]): void {
|
filesFor(collectionID: number, ...pages: FilesPage[]): void {
|
||||||
@@ -449,6 +459,137 @@ describe("Library ML-data fetch on refresh", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports a file whose ML data did not decode through status() and onProgress, and asks for it again", async () => {
|
||||||
|
const client = new MLMockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 100)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1001, 1, 90), file(1002, 1, 95)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 95,
|
||||||
|
});
|
||||||
|
client.mlByFile.set(1001, payload([0.5, 0.25, 0.75]));
|
||||||
|
client.mlErrorByFile.set(
|
||||||
|
1002,
|
||||||
|
new Error("secretstream chunk authentication failed"),
|
||||||
|
);
|
||||||
|
const events: RefreshEvent[] = [];
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshIntervalSeconds: Infinity,
|
||||||
|
onProgress: (event) => events.push(event),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await vi.waitFor(
|
||||||
|
() => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0),
|
||||||
|
{ timeout: 2000, interval: 5 },
|
||||||
|
);
|
||||||
|
const reasons = [
|
||||||
|
{
|
||||||
|
fileID: 1002,
|
||||||
|
error: "secretstream chunk authentication failed",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(lib.status().mlDataErrors).toEqual(reasons);
|
||||||
|
expect(lib.status().mlStored).toBe(1);
|
||||||
|
expect(events.filter((e) => e.operation === "fetchMLData")).toEqual(
|
||||||
|
[
|
||||||
|
{ operation: "fetchMLData", status: "started" },
|
||||||
|
{
|
||||||
|
operation: "fetchMLData",
|
||||||
|
status: "done",
|
||||||
|
fetched: 1,
|
||||||
|
mlDataErrors: reasons,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing was cached for 1002, so the pass a refresh starts asks
|
||||||
|
// for it again, and once its ML data decodes the reason is gone.
|
||||||
|
client.mlErrorByFile.clear();
|
||||||
|
client.mlByFile.set(1002, payload([1, -2, 0.5]));
|
||||||
|
await lib.fresh();
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
expect(lib.status().mlStored).toBe(2);
|
||||||
|
expect(lib.status().mlDataErrors).toEqual([]);
|
||||||
|
},
|
||||||
|
{ timeout: 2000, interval: 5 },
|
||||||
|
);
|
||||||
|
expect(client.mlFetchCalls.at(-1)).toEqual([1002]);
|
||||||
|
} finally {
|
||||||
|
await lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still lists the last pass's reasons while the next pass runs", async () => {
|
||||||
|
const client = new MLMockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 100)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1002, 1, 95)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 95,
|
||||||
|
});
|
||||||
|
client.mlErrorByFile.set(
|
||||||
|
1002,
|
||||||
|
new Error("secretstream chunk authentication failed"),
|
||||||
|
);
|
||||||
|
const reasons = [
|
||||||
|
{ fileID: 1002, error: "secretstream chunk authentication failed" },
|
||||||
|
];
|
||||||
|
let release!: () => void;
|
||||||
|
const held = new Promise<void>((r) => (release = r));
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshIntervalSeconds: Infinity,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await vi.waitFor(
|
||||||
|
() => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0),
|
||||||
|
{ timeout: 2000, interval: 5 },
|
||||||
|
);
|
||||||
|
expect(lib.status().mlDataErrors).toEqual(reasons);
|
||||||
|
|
||||||
|
// Hold the next ML data fetch open until the test releases it.
|
||||||
|
let fetchStarted!: () => void;
|
||||||
|
const started = new Promise<void>((r) => (fetchStarted = r));
|
||||||
|
const realFetch = client.fetchMLData.bind(client);
|
||||||
|
client.fetchMLData = async (args) => {
|
||||||
|
fetchStarted();
|
||||||
|
await held;
|
||||||
|
return realFetch(args);
|
||||||
|
};
|
||||||
|
client.mlErrorByFile.clear();
|
||||||
|
client.mlByFile.set(1002, payload([1, -2, 0.5]));
|
||||||
|
|
||||||
|
// The pass a refresh starts asks for 1002 again. While it is
|
||||||
|
// held, the reason from the pass before is still listed; once it
|
||||||
|
// ends, its own empty list takes the place of that one.
|
||||||
|
await lib.fresh();
|
||||||
|
await started;
|
||||||
|
expect(lib.status().mlDataErrors).toEqual(reasons);
|
||||||
|
release();
|
||||||
|
await vi.waitFor(
|
||||||
|
() => expect(lib.status().mlDataErrors).toEqual([]),
|
||||||
|
{ timeout: 2000, interval: 5 },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
await lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("close() resolves only after a running ML data fetch has stored its payloads", async () => {
|
it("close() resolves only after a running ML data fetch has stored its payloads", async () => {
|
||||||
const client = new MLMockClient();
|
const client = new MLMockClient();
|
||||||
client.collectionsQueue.push({
|
client.collectionsQueue.push({
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// `fetchMLDataBatch`, against a server that sends one file's ML data.
|
||||||
|
//
|
||||||
|
// ML data that decrypts and gunzips but is not a JSON object is reported with
|
||||||
|
// the reason, as ML data that does not parse is, and is never returned as a
|
||||||
|
// payload: the library would fail to store it.
|
||||||
|
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ApiClient } from "../../src/api/client.js";
|
||||||
|
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
|
||||||
|
import { fetchMLDataBatch } from "../../src/mldata-fetch.js";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
await sodium.ready;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch the ML data of file 1 from a server that sends `json`, gzipped and
|
||||||
|
// encrypted with the file's key.
|
||||||
|
const fetchServing = async (json: string) => {
|
||||||
|
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
|
||||||
|
const { header, ciphertext } = encryptBlob(
|
||||||
|
new Uint8Array(gzipSync(json)),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
fileID: 1,
|
||||||
|
encryptedData: toBase64(ciphertext),
|
||||||
|
decryptionHeader: toBase64(header),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const api = new ApiClient({
|
||||||
|
fetch: async () =>
|
||||||
|
new Response(JSON.stringify({ data }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return fetchMLDataBatch(api, [1], new Map([[1, key]]));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("fetchMLDataBatch", () => {
|
||||||
|
it.each(["null", "5", "[]"])(
|
||||||
|
"gives a reason for ML data that is %s",
|
||||||
|
async (json) => {
|
||||||
|
const { payloads, errors } = await fetchServing(json);
|
||||||
|
|
||||||
|
expect(payloads.size).toBe(0);
|
||||||
|
expect(errors.get(1)).toEqual(new Error("not a JSON object"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user