1 Commits
Author SHA1 Message Date
sneak d372283900 Names quak builds from server data fit in 255 bytes (closes #205)
check / check (push) Canceled after 0s
The default names of `quak get`, `quak get-thumb`, `downloadFile` and
`downloadThumbnail`, and the album folders of `backup-metadata`, could be
over 255 bytes, which a file system refuses. `shortenKeepingExtension` in
`src/filename.ts` cuts a name short, keeping its extension when it fits,
and can put text right before the extension; the backup's `withID` now
calls it instead of doing the same itself. The `get` defaults use it with
`thumb_` counted. A `backup-metadata` folder keeps its `<id>-` and has the
name after it cut short. `safeExtension` gives `.bin` for an extension of
more than 16 letters and digits, so a save path's name always fits.

Model: opus-5-5
2026-10-18 16:26:46 +00:00
42 changed files with 1586 additions and 6936 deletions
+1102 -1752
View File
File diff suppressed because it is too large Load Diff
+10 -84
View File
@@ -17,100 +17,26 @@ pre-1.0
# Next Step # Next Step
No bug is open, and no change sneak asked for is open. The check of quak against No bug is open. The check of quak against a live Ente account,
a live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an account https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak has, so
only sneak has, so it waits on him. 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-26: On an empty cache, `quak helper fix-missing-thumbnails --file`
stops fetching collections' files once it finds every file it names
(https://git.eeqj.de/sneak/quak/issues/223). It used to wait for a refresh,
which on an empty cache fetched the files of every collection. Now it reads
the collection list, then one collection's files at a time, the most recently
updated first. It stores none of the collections or file records it reads. It
fetches each original through `lib.originalOf()`, which keeps it in the cache.
Once the cache holds the account's collections, it still waits for a refresh.
- 2026-10-26: On an empty cache, `quak get` and `quak get-thumb` stop fetching
collections' files once they find the file
(https://git.eeqj.de/sneak/quak/issues/222). They used to wait for a refresh,
which on an empty cache fetched the files of every collection. Now they read
the collection list, then one collection's files at a time. They store none of
the collections or file records they read, and fetch the original or thumbnail
through the new `lib.originalOf()` and `lib.thumbnailOf()`. Once the cache
holds the account's collections, they still wait for a refresh.
- 2026-10-26: `quak files` with a collection ID fetches that collection's files
alone (https://git.eeqj.de/sneak/quak/issues/217). It used to wait for a
refresh, which on an empty cache fetched the files of every collection. It now
reads the collection list through `lib.freshCollections()`, then the one
collection's files through the new `lib.freshFiles()`. It stores nothing, and
the files print in the server's order.
- 2026-10-22: The JSON `quak backup` and `quak backup-metadata` write for a file
holds `keywords` in place of the CLIP embedding in its ML data
(https://git.eeqj.de/sneak/quak/issues/218). The keywords are the titles of
the Discover categories of Ente's photos app that the file falls in, by the
app's rule: the cosine similarity of the file's CLIP embedding and the text
embedding of a category's query is at least the category's minimum score, and
videos stay out of the categories that leave them out. `src/clip-keywords.ts`
applies the rule. `src/clip-keyword-vectors.ts` holds the 16 categories with
their text embeddings. `bin/clip-keyword-vectors` writes it with Ente's CLIP
text model, and writes the CLIP embedding of a public-domain photo of a sunset
that the tests use.
- 2026-10-18: `-v`/`--verbose` raises the CLI's log level once for each repeat,
and `-q`/`--quiet` prints only fatal errors
(https://git.eeqj.de/sneak/quak/issues/212). The levels are fatal, error,
warning, info, debug and trace; the default is info, everything quak printed
on stderr before, `-v` gives debug and `-vv` trace. `src/cli-log.ts` holds the
level and the log the commands write stderr lines through. Debug adds each
retry, which `withRetry` reports through the new `onRetry`, and the start and
end of each refresh and ML data fetch; trace adds each request, which
`ApiClient` reports through the new `onRequest`. `--json` no longer keeps the
progress lines off stderr, so `backup` and `--json` runs log a refresh's
progress too. Every line that reports a failed file or album is at error,
which the progress callbacks of `backup`, `backup-metadata` and the thumbnail
helpers now say, and a command that exits 1 because files or albums failed
ends with one fatal line saying how many and where to look.
- 2026-10-18: `quak files` takes the collection ID as an optional argument
instead of the `--collection` option, which is gone
(https://git.eeqj.de/sneak/quak/issues/211). With no argument it lists every
file in the account once, collection by collection; a file in several
collections is listed with the first of them, which is its `collectionID` in
`--json`. The fresh read in `src/cli-read.ts` does this.
- 2026-10-18: Names quak builds from an album name or a title fit in a file name - 2026-10-18: Names quak builds from an album name or a title fit in a file name
(https://git.eeqj.de/sneak/quak/issues/205). `shortenKeepingExtension` in (issue 205). `shortenKeepingExtension` in `src/filename.ts` cuts a name short
`src/filename.ts` cuts a name short to a number of bytes, keeping its to a number of bytes, keeping its extension when it fits, and can put text
extension when it fits and always its first character, and can put text right right before the extension; the backup's `withID` uses it for an ID. The
before the extension; the backup's `withID` uses it for an ID. default names of `quak get`, `quak get-thumb`, `downloadFile` and
`defaultFileName` there gives the name a file is saved under when the caller `downloadThumbnail` are cut short with it to 255 bytes, `thumb_` included.
gives no path: a prefix such as `thumb_` and the title with unsafe characters `backup-metadata` cuts an album's name short so that its folder,
replaced, cut short with it to 255 bytes, and, for a live photo's image or `<id>-<name>`, fits in 255 bytes. `safeExtension` gives `.bin` for an
video, with that part's own extension before the cut. `quak get`,
`quak get-thumb`, `downloadFile` and `downloadThumbnail` use it without
`--out` or `outPath`. `backup-metadata` cuts an album's name short so that its
folder, `<id>-<name>`, fits in 255 bytes. `safeExtension` gives `.bin` for an
extension of more than 16 letters and digits, so a save path's name always extension of more than 16 letters and digits, so a save path's name always
fits. fits.
- 2026-10-18: `quak collections` no longer waits for the files of every
collection (issue 210). On an empty cache it opened its library with a refresh
that fetched and decrypted the files of each collection in turn, printing
nothing and storing nothing until all were done, so on a large account it
seemed to hang. It now reads the collection list alone through the new
`lib.freshCollections()`, one request that stores nothing. The read commands
open their library with the new `refreshOnOpen: false`, so each makes only the
refresh it asks for. A refresh sends a `"progress"` event after each
collection whose files it fetched, and the commands that wait on one print it
on stderr, except with `--json`.
- 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is - 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 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 each such file beside the payloads; ML data that parses to something other
-31
View File
@@ -1,31 +0,0 @@
#!/bin/sh
# Writes src/clip-keyword-vectors.ts and test/clip-keywords-sunset.json by
# running bin/clip-keyword-vectors.py in a Python image pinned by digest, with
# its packages pinned by hash. Run it by hand from a checkout, then `make fmt`;
# no gate runs it. Besides the Python image, it downloads about 265 MB, most of
# it Ente's two CLIP models.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# python:3.13-slim (Python 3.13.17 on Debian 13.7), 2026-10-21. The wheels
# below are the ones for this image on linux/amd64.
IMAGE=python@sha256:57b29a7c050c4e679e4bea572e14b3965cf4b5839de2da49d55e49ffc4024f39
main() {
cd "$ROOT"
docker run --rm -i --platform linux/amd64 \
--user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$ROOT:/repo" -w /repo "$IMAGE" sh -c '
python -m venv /tmp/venv &&
/tmp/venv/bin/pip install --quiet --disable-pip-version-check \
--no-cache-dir --no-deps --require-hashes -r /dev/stdin &&
/tmp/venv/bin/python bin/clip-keyword-vectors.py' <<'EOF'
numpy==2.5.3 --hash=sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321
onnxruntime==1.30.0 --hash=sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f
pillow==12.3.0 --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3
tokenizers==0.23.2 --hash=sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305
EOF
}
main "$@"
-158
View File
@@ -1,158 +0,0 @@
"""Write src/clip-keyword-vectors.ts and test/clip-keywords-sunset.json.
bin/clip-keyword-vectors runs this in a pinned Python image. It downloads
Ente's Discover list, Ente's CLIP text and image models, the CLIP tokenizer
and a public-domain photo of a sunset, checks each against its sha256, and
writes:
- src/clip-keyword-vectors.ts: each Discover category of Ente's photos app,
with the text embedding Ente's CLIP text model gives its query;
- test/clip-keywords-sunset.json: the CLIP embedding Ente's image model gives
the photo, prepared as Ente's apps prepare a photo.
Both embeddings are scaled to length 1, as Ente's apps scale them.
"""
import hashlib
import io
import json
import urllib.request
import numpy as np
import onnxruntime
from PIL import Image, ImageOps
from tokenizers import Tokenizer
ENTE_COMMIT = "f5ad70a860ed25d0c8057b0efb4bd6eaefe4b0c5"
DISCOVER_PATH = "mobile/apps/photos/assets/discover.json"
DISCOVER_URL = (
f"https://raw.githubusercontent.com/ente-io/ente/{ENTE_COMMIT}/{DISCOVER_PATH}"
)
DISCOVER_SHA256 = "cf70ac7116b592f7921ca4cf67cdbb421a8e6b287fbe44b2be678b8d198e5c24"
TEXT_MODEL = "mobileclip_s2_text_opset18_quant.onnx"
TEXT_MODEL_SHA256 = "d92f33dfcff83077fc2e0d3414250710efbb51795dfd89767bdbefb5fdc47322"
IMAGE_MODEL = "mobileclip_s2_image_gelu_opset20.onnx"
IMAGE_MODEL_SHA256 = "205a430af825e501c5138e5bb9abea942482a7a4fd4a680e98e47cf0830dce7e"
# The CLIP tokenizer Hugging Face publishes for OpenAI's CLIP. Its vocabulary
# and merges are those of bpe_simple_vocab_16e6.txt, the vocabulary Ente's
# apps tokenize with.
TOKENIZER_URL = (
"https://huggingface.co/openai/clip-vit-base-patch32/resolve/"
"3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268/tokenizer.json"
)
TOKENIZER_SHA256 = "b556ac8c99757ffb677208af34bc8c6721572114111a6e0aaf5fa69ff0b8d842"
# "Sunset off the Massachusetts coast", a NOAA photo in the public domain:
# https://commons.wikimedia.org/wiki/File:Sunset_off_the_Massachusetts_coast_-_NOAA.jpg
PHOTO_URL = (
"https://upload.wikimedia.org/wikipedia/commons/d/db/"
"Sunset_off_the_Massachusetts_coast_-_NOAA.jpg"
)
PHOTO_SHA256 = "be62f3289c9bf7b75af68cd6978021d54e8a531d5d4cc493f6e9fbf5e1ab74d3"
# Ente's text model takes 77 token ids, padded with 0; its image model takes
# a 256x256 RGB image.
TOKEN_COUNT = 77
IMAGE_SIZE = 256
def download(url, sha256):
# Wikimedia refuses requests without a User-Agent.
request = urllib.request.Request(url, headers={"User-Agent": "quak"})
with urllib.request.urlopen(request) as response:
data = response.read()
digest = hashlib.sha256(data).hexdigest()
if digest != sha256:
raise SystemExit(f"{url} has sha256 {digest}, not {sha256}")
return data
def ente_model(name, sha256):
return onnxruntime.InferenceSession(
download(f"https://models.ente.com/{name}", sha256),
providers=["CPUExecutionProvider"],
)
def run(session, tensor):
(output,) = session.run(None, {session.get_inputs()[0].name: tensor})
embedding = output[0]
return embedding / np.linalg.norm(embedding)
def text_embedding(session, tokenizer, query):
ids = tokenizer.encode(query).ids
ids += [0] * (TOKEN_COUNT - len(ids))
return run(session, np.array([ids], dtype=np.int32))
def image_embedding(session, photo):
image = ImageOps.exif_transpose(Image.open(io.BytesIO(photo))).convert("RGB")
# Scale to cover IMAGE_SIZE x IMAGE_SIZE and crop the centre.
square = ImageOps.fit(image, (IMAGE_SIZE, IMAGE_SIZE), Image.Resampling.BILINEAR)
pixels = np.asarray(square, dtype=np.float32) / 255
return run(session, pixels.transpose(2, 0, 1)[np.newaxis])
def numbers(embedding):
return ", ".join(str(value) for value in embedding)
def write_categories(prompts, embeddings):
lines = [
"// Written by bin/clip-keyword-vectors; do not edit by hand.",
"//",
"// The Discover categories of Ente's photos app, in their order, from",
f"// {DISCOVER_PATH} in https://github.com/ente-io/ente",
f"// at commit {ENTE_COMMIT}. Each has the text",
f"// embedding Ente's CLIP text model ({TEXT_MODEL})",
"// gives its query, scaled to length 1. `showVideo` is false for a category",
"// that leaves out videos.",
"",
"export const DISCOVER_CATEGORIES = [",
]
for prompt, embedding in zip(prompts, embeddings):
lines += [
" {",
f" title: {json.dumps(prompt['title'])},",
f" query: {json.dumps(prompt['query'])},",
f" minScore: {json.dumps(prompt['minScore'])},",
f" showVideo: {json.dumps(prompt.get('showVideo', True))},",
f" textEmbedding: [{numbers(embedding)}],",
" },",
]
lines.append("];")
with open("src/clip-keyword-vectors.ts", "w", encoding="utf-8") as file:
file.write("\n".join(lines) + "\n")
def write_fixture(embedding):
with open("test/clip-keywords-sunset.json", "w", encoding="utf-8") as file:
file.write(
"{\n"
f' "photo": {json.dumps(PHOTO_URL)},\n'
f' "sha256": {json.dumps(PHOTO_SHA256)},\n'
f' "embedding": [{numbers(embedding)}]\n'
"}\n"
)
def main():
prompts = json.loads(download(DISCOVER_URL, DISCOVER_SHA256))["prompts"]
tokenizer = Tokenizer.from_str(
download(TOKENIZER_URL, TOKENIZER_SHA256).decode("utf-8")
)
text_model = ente_model(TEXT_MODEL, TEXT_MODEL_SHA256)
embeddings = [
text_embedding(text_model, tokenizer, prompt["query"]) for prompt in prompts
]
write_categories(prompts, embeddings)
image_model = ente_model(IMAGE_MODEL, IMAGE_MODEL_SHA256)
write_fixture(image_embedding(image_model, download(PHOTO_URL, PHOTO_SHA256)))
if __name__ == "__main__":
main()
+14 -47
View File
@@ -2,9 +2,8 @@
import { stdout, stderr } from "node:process"; import { stdout, stderr } from "node:process";
import { input, password } from "@inquirer/prompts"; import { input, password } from "@inquirer/prompts";
import { Command, Option } from "commander"; import { Command } from "commander";
import envPaths from "env-paths"; import envPaths from "env-paths";
import { logLevel, logRequests, stderrLog, type Log } from "../src/cli-log.js";
import { init } from "../src/crypto/index.js"; import { init } from "../src/crypto/index.js";
import { import {
type CliContext, type CliContext,
@@ -30,9 +29,6 @@ const paths = envPaths("quak", { suffix: "" });
const program = new Command(); const program = new Command();
// Program options, so they are taken before or after any command's name.
// `-v` counts its repeats, the way commander's documentation counts a repeated
// option.
program program
.name("quak") .name("quak")
.description("CLI for the Ente end-to-end encrypted photo service") .description("CLI for the Ente end-to-end encrypted photo service")
@@ -41,46 +37,21 @@ program
"--cache-dir <path>", "--cache-dir <path>",
"Directory for the local metadata/content cache " + "Directory for the local metadata/content cache " +
"(default: the per-user cache directory)", "(default: the per-user cache directory)",
) );
.option(
"-v, --verbose",
"Log more on stderr: debug lines with -v, trace lines too with -vv",
(_value: string, previous: number) => previous + 1,
0,
)
.addOption(
new Option(
"-q, --quiet",
"Print nothing on stderr but the error a failing command exits with",
).conflicts("verbose"),
)
.configureHelp({ showGlobalOptions: true });
interface ProgramOptions {
cacheDir?: string;
verbose: number;
quiet?: boolean;
}
// The log for this run, at the level `-v` and `-q` ask for.
const log = (): Log => {
const { verbose, quiet } = program.opts<ProgramOptions>();
return stderrLog(stderr, logLevel(verbose, quiet ?? false));
};
const context = (): CliContext => ({ const context = (): CliContext => ({
stdout, stdout,
log: log(), stderr,
sessionDir: paths.data, sessionDir: paths.data,
cacheDir: program.opts<ProgramOptions>().cacheDir, cacheDir: program.opts<{ cacheDir?: string }>().cacheDir,
loadSession: (path) => loadSession(path, logRequests(log())), loadSession,
login: (opts) => Client.login({ ...opts, apiOptions: logRequests(log()) }), login: (opts) => Client.login(opts),
prompt: (message) => input({ message }), prompt: (message) => input({ message }),
promptSecret: (message) => password({ message, mask: true }), promptSecret: (message) => password({ message, mask: true }),
}); });
const run = (command: Promise<number>): Promise<void> => const run = (command: Promise<number>): Promise<void> =>
runCommand(command, stdout, stderr, log(), (code) => process.exit(code)); runCommand(command, stdout, stderr, (code) => process.exit(code));
program program
.command("login") .command("login")
@@ -107,15 +78,14 @@ program
program program
.command("files") .command("files")
.description("List the files in a collection, or every file in the account") .description("List files in a collection")
.argument( .requiredOption(
"[collectionID]", "--collection <id>",
"Collection ID (from `quak collections`); without it, every file " + "Collection ID (from `quak collections`)",
"is listed once",
) )
.option("--json", "Output as JSON array") .option("--json", "Output as JSON array")
.action((collectionID: string | undefined, opts: { json?: boolean }) => .action((opts: { collection: string; json?: boolean }) =>
run(filesCommand(context(), collectionID, opts)), run(filesCommand(context(), opts)),
); );
program program
@@ -172,10 +142,7 @@ program
{ {
...context(), ...context(),
loadSession: (path) => loadSession: (path) =>
loadSession( loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }),
path,
logRequests(log(), UNATTENDED_RETRY_OPTIONS),
),
}, },
dir, dir,
opts, opts,
+6 -51
View File
@@ -36,15 +36,6 @@ export interface ApiClientOptions {
retry?: RetryOptions; retry?: RetryOptions;
requestTimeoutMs?: number; requestTimeoutMs?: number;
downloadTimeoutMs?: number; downloadTimeoutMs?: number;
// Called once for each request sent, with the status of its response or
// the error it failed with, for a caller that logs requests. A request
// that is retried is reported once for each attempt.
onRequest?: (request: {
method: string;
url: string;
status?: number;
error?: unknown;
}) => void;
} }
export interface StreamOptions { export interface StreamOptions {
@@ -168,7 +159,6 @@ export class ApiClient {
private readonly retry: ResolvedRetryOptions; private readonly retry: ResolvedRetryOptions;
private readonly requestTimeoutMs: number; private readonly requestTimeoutMs: number;
private readonly downloadTimeoutMs: number; private readonly downloadTimeoutMs: number;
private readonly onRequest: NonNullable<ApiClientOptions["onRequest"]>;
private token: string | undefined; private token: string | undefined;
constructor(opts?: ApiClientOptions) { constructor(opts?: ApiClientOptions) {
@@ -190,7 +180,6 @@ export class ApiClient {
opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
this.downloadTimeoutMs = this.downloadTimeoutMs =
opts?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS; opts?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
this.onRequest = opts?.onRequest ?? (() => {});
this.token = opts?.authToken; this.token = opts?.authToken;
} }
@@ -225,33 +214,6 @@ export class ApiClient {
return h; return h;
} }
// Send one request and report it to `onRequest`, with the status of its
// response or the error it failed with. `reportedURL` is the URL given to
// `onRequest`, when that must not be the one sent.
private async send(
url: string,
init: RequestInit & { method: string },
reportedURL = url,
): Promise<Response> {
let resp: Response;
try {
resp = await this._fetch(url, init);
} catch (err) {
this.onRequest({
method: init.method,
url: reportedURL,
error: err,
});
throw err;
}
this.onRequest({
method: init.method,
url: reportedURL,
status: resp.status,
});
return resp;
}
private async throwIfError(resp: Response): Promise<void> { private async throwIfError(resp: Response): Promise<void> {
if (resp.ok) return; if (resp.ok) return;
const requestID = resp.headers.get("x-request-id") ?? undefined; const requestID = resp.headers.get("x-request-id") ?? undefined;
@@ -295,7 +257,7 @@ export class ApiClient {
const url = buildURL(this.apiOrigin, path, query); const url = buildURL(this.apiOrigin, path, query);
// A GET changes nothing, so it is retried under the full policy. // A GET changes nothing, so it is retried under the full policy.
return withRetry(async () => { return withRetry(async () => {
const resp = await this.send(url, { const resp = await this._fetch(url, {
method: "GET", method: "GET",
headers: this.headers(), headers: this.headers(),
signal: AbortSignal.timeout(this.requestTimeoutMs), signal: AbortSignal.timeout(this.requestTimeoutMs),
@@ -318,7 +280,7 @@ export class ApiClient {
// 3xx status, which is not retried. // 3xx status, which is not retried.
return withRetry( return withRetry(
async () => { async () => {
const resp = await this.send(url, { const resp = await this._fetch(url, {
method: "POST", method: "POST",
headers: this.headers({ headers: this.headers({
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -359,13 +321,8 @@ export class ApiClient {
// object at one key in one request, so replaying it either overwrites // object at one key in one request, so replaying it either overwrites
// the same bytes or lands them for the first time. There is no partial // the same bytes or lands them for the first time. There is no partial
// state to protect, hence the full policy rather than the POST rule. // state to protect, hence the full policy rather than the POST rule.
// It is reported without its query, which holds the upload's
// signature: a log keeps what it is given.
const { origin, pathname } = new URL(presignedURL);
await withRetry(async () => { await withRetry(async () => {
const resp = await this.send( const resp = await this._fetch(presignedURL, {
presignedURL,
{
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
@@ -373,9 +330,7 @@ export class ApiClient {
}, },
body: data, body: data,
signal: AbortSignal.timeout(this.requestTimeoutMs), signal: AbortSignal.timeout(this.requestTimeoutMs),
}, });
origin + pathname,
);
if (!resp.ok) { if (!resp.ok) {
// An ApiError, not a bare Error: without the status on the // An ApiError, not a bare Error: without the status on the
// error the upload path cannot be classified at all, and a // error the upload path cannot be classified at all, and a
@@ -393,7 +348,7 @@ export class ApiClient {
// Same replay and redirect rules as `postJSON`, for the same reasons. // Same replay and redirect rules as `postJSON`, for the same reasons.
return withRetry( return withRetry(
async () => { async () => {
const resp = await this.send(url, { const resp = await this._fetch(url, {
method: "PUT", method: "PUT",
headers: this.headers({ headers: this.headers({
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -439,7 +394,7 @@ export class ApiClient {
// headers, when no bytes have arrived either. // headers, when no bytes have arrived either.
const deadline = idleDeadline(this.downloadTimeoutMs); const deadline = idleDeadline(this.downloadTimeoutMs);
try { try {
const resp = await this.send(url, { const resp = await this._fetch(url, {
method: "GET", method: "GET",
headers: this.headers(), headers: this.headers(),
signal: deadline.signal, signal: deadline.signal,
+14 -33
View File
@@ -14,9 +14,9 @@
// YYYY/YYYY-MM/YYYY-MM-DD/ // YYYY/YYYY-MM/YYYY-MM-DD/
// YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path) // YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path)
// YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with // YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with
// the file's ML data, its // the file's ML data and its
// keywords, and its original's // original's EXIF, XMP and
// EXIF, XMP and dimensions // dimensions
// collections/<name>/<title> symlink to the original // collections/<name>/<title> symlink to the original
// collections/<name>.json per-collection metadata // collections/<name>.json per-collection metadata
// account.json the account's email and user ID // account.json the account's email and user ID
@@ -77,7 +77,6 @@ import { readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path"; import { dirname, extname, join, relative, resolve } from "node:path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import { clipKeywords } from "./clip-keywords.js";
import { removeLeftoverTempFiles } from "./download/index.js"; import { removeLeftoverTempFiles } from "./download/index.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
import { import {
@@ -102,10 +101,7 @@ import type { MLData } from "./mldata-fetch.js";
import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
import { hasTransportCode } from "./retry.js"; import { hasTransportCode } from "./retry.js";
// Reports one line of the backup's progress. `level` is "error" when the line export type ProgressCallback = (message: string) => void;
// reports something that failed, such as a file or an album, and is left out
// for every other line.
export type ProgressCallback = (message: string, level?: "error") => void;
// The lock in a backup directory, as `lockBackupDirectory` returns it. // The lock in a backup directory, as `lockBackupDirectory` returns it.
export interface BackupLock { export interface BackupLock {
@@ -294,10 +290,9 @@ const rebuildSymlink = (linkPath: string, target: string): void => {
// case (two names that differ only in case are one entry on a case-insensitive // case (two names that differ only in case are one entry on a case-insensitive
// file system), or it is too long for a file name; then it gets ` (<id>)`, // file system), or it is too long for a file name; then it gets ` (<id>)`,
// right before the extension when `beforeExtension` is set. To fit, the part // right before the extension when `beforeExtension` is set. To fit, the part
// before the ID is cut short as far as it must be. When the extension leaves // before the ID is cut short as far as it must be. When the extension alone
// no room for the ID and the first character of the name, the extension is // leaves no room for the ID, the extension is cut short and the part before
// cut short, and the part before the ID keeps that character and any bytes // the ID keeps only the bytes that cut leaves over. With `withJSON`, each
// the cut leaves over (see `shortenKeepingExtension`). With `withJSON`, each
// entry also has the JSON file `<name>.json` beside it, as an album does: a // entry also has the JSON file `<name>.json` beside it, as an album does: a
// name then leaves room for `.json`, and one that is another entry's JSON name // name then leaves room for `.json`, and one that is another entry's JSON name
// gets its ID added too. // gets its ID added too.
@@ -498,9 +493,8 @@ const imageMetadataFor = async (
} }
}; };
// The file's JSON: its basic fields, its magic metadata, its ML data without // The file's JSON: its basic fields, its magic metadata, its ML data or the
// the CLIP embedding and the keywords that embedding gives, or the reason the // reason the ML data is missing, and its image metadata.
// ML data is missing, and its image metadata.
const writeSidecar = ( const writeSidecar = (
path: string, path: string,
file: EnteFile, file: EnteFile,
@@ -516,13 +510,7 @@ const writeSidecar = (
}; };
if (file.magicMetadata) meta.magicMetadata = file.magicMetadata; if (file.magicMetadata) meta.magicMetadata = file.magicMetadata;
if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata; if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata;
if (ml.mlData) { if (ml.mlData) meta.mlData = ml.mlData;
const { clip: _clip, ...mlDataWithoutClip } = ml.mlData;
meta.mlData = mlDataWithoutClip;
const isVideo = file.metadata.fileType === "video";
const keywords = clipKeywords(ml.mlData, isVideo);
if (keywords) meta.keywords = keywords;
}
if (ml.mlDataError) meta.mlDataError = ml.mlDataError; if (ml.mlDataError) meta.mlDataError = ml.mlDataError;
if (image.imageMetadata) meta.imageMetadata = image.imageMetadata; if (image.imageMetadata) meta.imageMetadata = image.imageMetadata;
if (image.imageMetadataError) { if (image.imageMetadataError) {
@@ -665,7 +653,7 @@ const runLockedBackup = async (
for (const record of lib.undecryptable()) { for (const record of lib.undecryptable()) {
const error = `could not be decrypted: ${record.error}`; const error = `could not be decrypted: ${record.error}`;
if (record.fileID === undefined) { if (record.fileID === undefined) {
log(`FAILED album ${record.collectionID}: ${error}`, "error"); log(`FAILED album ${record.collectionID}: ${error}`);
undecryptableAlbums.push(record.collectionID); undecryptableAlbums.push(record.collectionID);
} else if (scope.has(record.collectionID)) { } else if (scope.has(record.collectionID)) {
const copies = memberships.get(record.fileID); const copies = memberships.get(record.fileID);
@@ -710,7 +698,6 @@ const runLockedBackup = async (
} catch (err) { } catch (err) {
log( log(
`FAILED verifying original ${file.metadata.title}: ${describeError(err)}`, `FAILED verifying original ${file.metadata.title}: ${describeError(err)}`,
"error",
); );
recordFailure( recordFailure(
file.id, file.id,
@@ -744,7 +731,6 @@ const runLockedBackup = async (
} catch (err) { } catch (err) {
log( log(
`FAILED original ${file.metadata.title}: ${describeError(err)}`, `FAILED original ${file.metadata.title}: ${describeError(err)}`,
"error",
); );
recordFailure( recordFailure(
file.id, file.id,
@@ -820,7 +806,7 @@ const runLockedBackup = async (
await lib.fetchMLData(); await lib.fetchMLData();
} catch (err) { } catch (err) {
mlDataFailure = err; mlDataFailure = err;
log(`FAILED ML data: ${describeError(err)}`, "error"); log(`FAILED ML data: ${describeError(err)}`);
} }
for (const file of distinct.values()) { for (const file of distinct.values()) {
if (lock.lost) throw lock.lost; if (lock.lost) throw lock.lost;
@@ -881,10 +867,7 @@ const runLockedBackup = async (
downloadDirectory, downloadDirectory,
); );
} catch (err) { } catch (err) {
log( log(`FAILED removing old album directories: ${describeError(err)}`);
`FAILED removing old album directories: ${describeError(err)}`,
"error",
);
} }
// An album whose folder or JSON cannot be written, such as one whose // An album whose folder or JSON cannot be written, such as one whose
@@ -913,7 +896,6 @@ const runLockedBackup = async (
} catch (err) { } catch (err) {
log( log(
`FAILED removing old links in ${c.name}: ${describeError(err)}`, `FAILED removing old links in ${c.name}: ${describeError(err)}`,
"error",
); );
} }
@@ -932,7 +914,6 @@ const runLockedBackup = async (
} catch (err) { } catch (err) {
log( log(
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`, `FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`,
"error",
); );
recordFailure( recordFailure(
link.file.id, link.file.id,
@@ -949,7 +930,7 @@ const runLockedBackup = async (
metaFiles, metaFiles,
); );
} catch (err) { } catch (err) {
log(`FAILED album ${c.name}: ${describeError(err)}`, "error"); log(`FAILED album ${c.name}: ${describeError(err)}`);
failedAlbums.push(c.id); failedAlbums.push(c.id);
} }
} }
+117 -159
View File
@@ -4,10 +4,7 @@
// code; a thrown error is left to the caller. Nothing here calls // code; a thrown error is left to the caller. Nothing here calls
// `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in // `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in
// `cli-run.ts` prints a thrown error as one line and exits once output has // `cli-run.ts` prints a thrown error as one line and exits once output has
// drained. Output must stay byte-identical (see `cli-output.ts`). Each line on // drained. Output must stay byte-identical (see `cli-output.ts`).
// stderr goes through `ctx.log` at its level (see `cli-log.ts`). Every command
// that exits non-zero logs a fatal line saying why; one that exits 1 because
// files or albums failed logs it last, saying how many and where to look.
import { import {
copyFileSync, copyFileSync,
@@ -36,23 +33,14 @@ import {
originalName, originalName,
thumbnailName, thumbnailName,
} from "./cli-output.js"; } from "./cli-output.js";
import { type Log, logRefresh } from "./cli-log.js"; import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
import {
cachedFilesByID,
freshCollections,
freshFiles,
freshFile,
freshFilesByID,
} from "./cli-read.js";
import { withExtension } from "./filename.js"; import { withExtension } from "./filename.js";
import { runMetadataBackup } from "./metadata-backup.js"; import { runMetadataBackup } from "./metadata-backup.js";
import type { Collection, EnteFile } from "./model/types.js";
import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js"; import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
export interface CliContext { export interface CliContext {
stdout: { write(text: string): unknown }; stdout: { write(text: string): unknown };
// Writes a line on stderr at a log level, if the run's level prints it. stderr: { write(text: string): unknown };
log: Log;
// Directory holding `session.json`. // Directory holding `session.json`.
sessionDir: string; sessionDir: string;
// The `--cache-dir` global, or undefined to let the library pick its // The `--cache-dir` global, or undefined to let the library pick its
@@ -94,16 +82,16 @@ const requireSession = (ctx: CliContext): Client | undefined => {
try { try {
client = ctx.loadSession(sessionPath(ctx)); client = ctx.loadSession(sessionPath(ctx));
} catch (err) { } catch (err) {
ctx.log("fatal", err instanceof Error ? err.message : String(err)); ctx.stderr.write(
ctx.log( `${err instanceof Error ? err.message : err}\n` +
"fatal", `Run "quak logout" and then "quak login" to replace it.\n`,
`Run "quak logout" and then "quak login" to replace it.`,
); );
return undefined; return undefined;
} }
if (!client) { if (!client) {
ctx.log("fatal", `Not logged in. Run "quak login" first.`); ctx.stderr.write(
ctx.log("fatal", `Session file: ${sessionPath(ctx)}`); `Not logged in. Run "quak login" first.\nSession file: ${sessionPath(ctx)}\n`,
);
return undefined; return undefined;
} }
return client; return client;
@@ -119,7 +107,7 @@ const parseID = (
): number | undefined => { ): number | undefined => {
const id = Number(text); const id = Number(text);
if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id; if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id;
ctx.log("fatal", `Invalid ${kind} ID: ${text}`); ctx.stderr.write(`Invalid ${kind} ID: ${text}\n`);
return undefined; return undefined;
}; };
@@ -135,24 +123,17 @@ const readLibraryClient = (client: Client): LibraryClient => ({
contentSource: () => client.contentSource(), contentSource: () => client.contentSource(),
}); });
// Open a library for a single point command. The aggressive background // Open a library for a single point command: the aggressive background precache
// precache (issue #48) is off, because a one-shot `collections` or `get` must // (issue #48) is off — a one-shot `collections` or `get` must not start
// not start downloading the whole account. No refresh starts as the library // downloading the whole account — and the refresh interval is long so no second
// opens or on a timer. Each command asks for the refresh it needs. // refresh fires mid-command.
// `collections` and `files` with a collection ID need none (issues #210 and
// #217). Nor do `get` and `get-thumb` on an empty cache (issue #222). Nor does
// `helper fix-missing-thumbnails --file` on an empty cache (issue #223). While
// a refresh runs, each collection whose files it has fetched is logged, so a
// large account shows progress rather than nothing.
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> => const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
Library.open({ Library.open({
client: readLibraryClient(client), client: readLibraryClient(client),
cacheDirectory: ctx.cacheDir, cacheDirectory: ctx.cacheDir,
refreshOnOpen: false, refreshIntervalSeconds: 3600,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false, precacheThumbnails: false,
precacheOriginals: false, precacheOriginals: false,
onProgress: logRefresh(ctx.log),
}); });
export const loginCommand = async (ctx: CliContext): Promise<number> => { export const loginCommand = async (ctx: CliContext): Promise<number> => {
@@ -161,7 +142,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
const password = const password =
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password")); process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
ctx.log("info", "Authenticating..."); ctx.stderr.write("Authenticating...\n");
try { try {
const client = await ctx.login({ const client = await ctx.login({
email, email,
@@ -172,12 +153,11 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
saveSession(ctx.sessionDir, client.toJSON()); saveSession(ctx.sessionDir, client.toJSON());
const info = client.whoami(); const info = client.whoami();
ctx.log("info", `Logged in as ${info.email} (user ${info.userID})`); ctx.stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`);
ctx.log("info", `Session saved to ${sessionPath(ctx)}`); ctx.stderr.write(`Session saved to ${sessionPath(ctx)}\n`);
} catch (err) { } catch (err) {
ctx.log( ctx.stderr.write(
"fatal", `Login failed: ${err instanceof Error ? err.message : err}\n`,
`Login failed: ${err instanceof Error ? err.message : err}`,
); );
return 1; return 1;
} }
@@ -199,7 +179,7 @@ export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
export const logoutCommand = async (ctx: CliContext): Promise<number> => { export const logoutCommand = async (ctx: CliContext): Promise<number> => {
const path = sessionPath(ctx); const path = sessionPath(ctx);
if (!existsSync(path)) { if (!existsSync(path)) {
ctx.log("info", "No session found."); ctx.stderr.write("No session found.\n");
return 0; return 0;
} }
await init(); await init();
@@ -217,15 +197,16 @@ export const logoutCommand = async (ctx: CliContext): Promise<number> => {
} }
unlinkSync(path); unlinkSync(path);
if (failure === undefined) { if (failure === undefined) {
ctx.log("info", "Session ended on the server."); ctx.stderr.write("Session ended on the server.\n");
} else { } else {
ctx.log("fatal", `Could not end the session on the server: ${failure}`); ctx.stderr.write(
`Could not end the session on the server: ${failure}\n`,
);
} }
ctx.log("info", "Session deleted."); ctx.stderr.write("Session deleted.\n");
if (cacheDir !== undefined) { if (cacheDir !== undefined) {
ctx.log( ctx.stderr.write(
"warning", `Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`,
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.`,
); );
} }
return failure === undefined ? 0 : 1; return failure === undefined ? 0 : 1;
@@ -240,10 +221,9 @@ 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 {
// Ask the server for the collection list alone and list it in its // Force a server round-trip and list in enumeration order (issue #36
// order (issue #36 amendment, issues #52 and #210): the pre-library // amendment, issue #52): the pre-library CLI printed current state in
// CLI printed current state in this order, not the albums // this order, not the albums projection's newest-first order.
// projection's newest-first order.
const collections = await freshCollections(lib); const collections = await freshCollections(lib);
if (opts.json) { if (opts.json) {
@@ -274,32 +254,26 @@ export const collectionsCommand = async (
} }
}; };
// Lists the files of the collection `collectionIDStr` names, or without it every
// file in the account, each once (issue #211).
export const filesCommand = async ( export const filesCommand = async (
ctx: CliContext, ctx: CliContext,
collectionIDStr: string | undefined, opts: { collection: string; json?: boolean },
opts: { json?: boolean },
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
const collectionID = const collectionID = parseID(ctx, "collection", opts.collection);
collectionIDStr === undefined if (collectionID === undefined) return 1;
? undefined
: parseID(ctx, "collection", collectionIDStr);
if (collectionIDStr !== undefined && collectionID === undefined) return 1;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Ask the server for current state (issue #36 amendment, issue #52). // Force a server round-trip and list in enumeration order (issue #36
// Each file prints from its own decrypted metadata (raw title, // amendment, issue #52). Each file prints from its own decrypted
// microsecond creationTime) via cli-output. The files print in the // metadata (raw title, microsecond creationTime) via cli-output, and in
// order `freshFiles` returns them, not the projection's newest-first // the pre-library CLI's enumeration order, not the projection's
// order. // newest-first order.
const files = await freshFiles(lib, collectionID); const files = await freshFiles(lib, collectionID);
if (!files) { if (!files) {
ctx.log("fatal", `Collection ${collectionID} not found`); ctx.stderr.write(`Collection ${collectionID} not found\n`);
return 1; return 1;
} }
@@ -333,42 +307,40 @@ export const getCommand = async (
try { try {
// Force a server round-trip so the file resolves against current state // Force a server round-trip so the file resolves against current state
// (issue #36 amendment, issue #52). // (issue #36 amendment, issue #52).
const file = await freshFile(lib, fileID); const resolved = await freshFile(lib, fileID);
if (!file) { if (!resolved) {
ctx.log("fatal", `File ${fileID} not found`); ctx.stderr.write(`File ${fileID} not found\n`);
return 1; return 1;
} }
const { photo, file } = resolved;
const result = await lib.originalOf(file); const result = await photo.original();
// Default name is the file's own title, as the pre-library CLI used // Default name is the file's own title, as the pre-library CLI used
// (not the editedName-preferring projection title) (issue #52). // (not the editedName-preferring projection title) (issue #52).
if (result.videoPath === undefined) {
const outPath = opts.out ?? originalName(file); const outPath = opts.out ?? originalName(file);
if (result.videoPath === undefined) {
copyFileSync(result.path, outPath); copyFileSync(result.path, outPath);
ctx.log("info", `${result.bytes} bytes -> ${outPath}`); ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
return 0; return 0;
} }
// A live photo is written as its image and its video, each named after // A live photo is written as its image and its video, each named after
// the title with its own extension, as Ente's clients name them. With // the title with its own extension, as Ente's clients name them. With
// --out, the image goes there and the video beside it. // --out, the image goes there and the video beside it.
const imageExt = extname(result.path); const imageOut =
const videoExt = extname(result.videoPath); opts.out ?? withExtension(outPath, extname(result.path));
const imageOut = opts.out ?? originalName(file, imageExt); const videoOut = withExtension(outPath, extname(result.videoPath));
const videoOut =
opts.out === undefined
? originalName(file, videoExt)
: withExtension(opts.out, videoExt);
if (imageOut.toLowerCase() === videoOut.toLowerCase()) { if (imageOut.toLowerCase() === videoOut.toLowerCase()) {
ctx.log( ctx.stderr.write(
"fatal", `File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`,
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}`,
); );
return 1; return 1;
} }
copyFileSync(result.path, imageOut); copyFileSync(result.path, imageOut);
copyFileSync(result.videoPath, videoOut); copyFileSync(result.videoPath, videoOut);
ctx.log("info", `${result.bytes} bytes -> ${imageOut}`); ctx.stderr.write(
ctx.log("info", `${statSync(videoOut).size} bytes -> ${videoOut}`); `${result.bytes} bytes -> ${imageOut}\n` +
`${statSync(videoOut).size} bytes -> ${videoOut}\n`,
);
return 0; return 0;
} finally { } finally {
await lib.close(); await lib.close();
@@ -390,18 +362,19 @@ export const getThumbCommand = async (
try { try {
// Force a server round-trip so the file resolves against current state // Force a server round-trip so the file resolves against current state
// (issue #36 amendment, issue #52). // (issue #36 amendment, issue #52).
const file = await freshFile(lib, fileID); const resolved = await freshFile(lib, fileID);
if (!file) { if (!resolved) {
ctx.log("fatal", `File ${fileID} not found`); ctx.stderr.write(`File ${fileID} not found\n`);
return 1; return 1;
} }
const { photo, file } = resolved;
const result = await lib.thumbnailOf(file); const result = await photo.thumbnail();
// Default name is thumb_<file's own title>, as the pre-library CLI // Default name is thumb_<file's own title>, as the pre-library CLI
// used (not the projection title) (issue #52). // used (not the projection title) (issue #52).
const outPath = opts.out ?? thumbnailName(file); const outPath = opts.out ?? thumbnailName(file);
copyFileSync(result.path, outPath); copyFileSync(result.path, outPath);
ctx.log("info", `${result.bytes} bytes -> ${outPath}`); ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
return 0; return 0;
} finally { } finally {
await lib.close(); await lib.close();
@@ -423,14 +396,9 @@ export const backupMetadataCommand = async (
await lib.fresh(); await lib.fresh();
const result = await runMetadataBackup(lib, client, dir, { const result = await runMetadataBackup(lib, client, dir, {
exif: opts.exif || opts.all, exif: opts.exif || opts.all,
onProgress: (msg, level) => ctx.log(level ?? "info", msg), onProgress: (msg) => ctx.stderr.write(msg + "\n"),
}); });
if (result.filesWithMLDataError === 0) return 0; return result.filesWithMLDataError > 0 ? 1 : 0;
ctx.log(
"fatal",
`quak: the ML data of ${result.filesWithMLDataError} file(s) could not be read; see mlDataError in their JSON in ${dir}`,
);
return 1;
} finally { } finally {
await lib.close(); await lib.close();
} }
@@ -445,7 +413,7 @@ export const backupCommand = async (
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
ctx.log("info", "Starting backup..."); ctx.stderr.write("Starting backup...\n");
// The lock is taken before the library opens and starts its refresh, so a // The lock is taken before the library opens and starts its refresh, so a
// run refused while another backup of `dir` runs sends no request. // run refused while another backup of `dir` runs sends no request.
let lock: BackupLock; let lock: BackupLock;
@@ -453,7 +421,7 @@ export const backupCommand = async (
lock = await lockBackupDirectory(dir); lock = await lockBackupDirectory(dir);
} catch (err) { } catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
ctx.log("fatal", `quak: ${(err as Error).message}`); ctx.stderr.write(`quak: ${(err as Error).message}\n`);
return 2; return 2;
} }
try { try {
@@ -468,48 +436,45 @@ export const backupCommand = async (
refreshIntervalSeconds: Infinity, refreshIntervalSeconds: Infinity,
precacheThumbnails: false, precacheThumbnails: false,
precacheOriginals: false, precacheOriginals: false,
onProgress: logRefresh(ctx.log),
}); });
try { try {
const result = await lib.backup({ const result = await lib.backup({
downloadDirectory: dir, downloadDirectory: dir,
lock, lock,
verify: opts.verify, verify: opts.verify,
onProgress: (msg, level) => ctx.log(level ?? "info", msg), onProgress: (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
},
}); });
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(result, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else { } else {
ctx.log("info", "\n--- Backup complete ---"); ctx.stderr.write("\n--- Backup complete ---\n");
ctx.log("info", ` Total files: ${result.totalFiles}`); ctx.stderr.write(` Total files: ${result.totalFiles}\n`);
ctx.log("info", ` Downloaded: ${result.downloaded}`); ctx.stderr.write(` Downloaded: ${result.downloaded}\n`);
ctx.log("info", ` Skipped: ${result.skipped}`); ctx.stderr.write(` Skipped: ${result.skipped}\n`);
if (opts.verify) { if (opts.verify) {
ctx.log("info", ` Verified: ${result.verified}`); ctx.stderr.write(` Verified: ${result.verified}\n`);
ctx.log("info", ` Mismatched: ${result.mismatched}`); ctx.stderr.write(` Mismatched: ${result.mismatched}\n`);
ctx.log("info", ` Unchecked: ${result.unchecked}`); ctx.stderr.write(` Unchecked: ${result.unchecked}\n`);
} }
ctx.log("info", ` Failed: ${result.failed}`); ctx.stderr.write(` Failed: ${result.failed}\n`);
if (result.errors.length > 0) { if (result.errors.length > 0) {
ctx.log("error", "\nFailed files:"); ctx.stderr.write("\nFailed files:\n");
for (const e of result.errors) { for (const e of result.errors) {
ctx.log( ctx.stderr.write(
"error", ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}`,
); );
} }
} }
} }
const albums = return result.failed > 0 ||
result.undecryptableAlbums.length + result.failedAlbums.length; result.undecryptableAlbums.length > 0 ||
if (result.failed === 0 && albums === 0) return 0; result.failedAlbums.length > 0
ctx.log( ? 1
"fatal", : 0;
`quak: ${result.failed} file(s) and ${albums} album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines`,
);
return 1;
} finally { } finally {
await lib.close(); await lib.close();
} }
@@ -530,19 +495,18 @@ export const listMissingThumbnailsCommand = async (
// 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.
await lib.fresh(); await lib.fresh();
const missing = await listMissingThumbnails(lib, client, (msg, level) => const missing = await listMissingThumbnails(lib, client, (msg) => {
ctx.log(level ?? "info", msg), if (!opts.json) ctx.stderr.write(msg + "\n");
); });
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(missing, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(missing, null, 2) + "\n");
} else { } else {
if (missing.length === 0) { if (missing.length === 0) {
ctx.log("info", "No missing thumbnails found."); ctx.stderr.write("No missing thumbnails found.\n");
} else { } else {
ctx.log( ctx.stderr.write(
"info", `\n${missing.length} file(s) with missing thumbnails:\n`,
`\n${missing.length} file(s) with missing thumbnails:`,
); );
for (const m of missing) { for (const m of missing) {
ctx.stdout.write( ctx.stdout.write(
@@ -573,37 +537,31 @@ export const fixMissingThumbnailsCommand = async (
} }
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
let found: Map<number, { file: EnteFile; collection: Collection }>; // Refresh first so files added since the cache was written are found;
if (fileIDs.length > 0) { // a failed refresh throws.
found = await freshFilesByID(lib, fileIDs);
} else {
// Refresh first so files added since the cache was written are
// checked; a failed refresh throws.
await lib.fresh(); await lib.fresh();
ctx.log("info", "Scanning for missing thumbnails..."); if (fileIDs.length === 0) {
const missing = await listMissingThumbnails( ctx.stderr.write("Scanning for missing thumbnails...\n");
lib, const missing = await listMissingThumbnails(lib, client, (msg) => {
client, if (!opts.json) ctx.stderr.write(msg + "\n");
(msg, level) => ctx.log(level ?? "info", msg), });
);
fileIDs = missing.map((m) => m.fileID); fileIDs = missing.map((m) => m.fileID);
if (fileIDs.length === 0) { if (fileIDs.length === 0) {
ctx.log("info", "No missing thumbnails found."); ctx.stderr.write("No missing thumbnails found.\n");
return 0; return 0;
} }
ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`); ctx.stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`);
found = cachedFilesByID(lib, fileIDs);
} }
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(
lib, lib,
client, client,
fileIDs, fileIDs,
found, (msg) => {
(msg, level) => ctx.log(level ?? "info", msg), if (!opts.json) ctx.stderr.write(msg + "\n");
},
); );
const failed = results.filter((r) => r.status === "failed").length;
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(results, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(results, null, 2) + "\n");
} else { } else {
@@ -611,30 +569,30 @@ export const fixMissingThumbnailsCommand = async (
const skipped = results.filter( const skipped = results.filter(
(r) => r.status === "skipped", (r) => r.status === "skipped",
).length; ).length;
ctx.log("info", `\n--- Done ---`); const failed = results.filter((r) => r.status === "failed").length;
ctx.log("info", ` Fixed: ${fixed}`); ctx.stderr.write(`\n--- Done ---\n`);
ctx.log("info", ` Skipped: ${skipped}`); ctx.stderr.write(` Fixed: ${fixed}\n`);
ctx.log("info", ` Failed: ${failed}`); ctx.stderr.write(` Skipped: ${skipped}\n`);
ctx.stderr.write(` Failed: ${failed}\n`);
if (skipped > 0) { if (skipped > 0) {
ctx.log("info", "\nSkipped:"); ctx.stderr.write("\nSkipped:\n");
for (const r of results.filter((r) => r.status === "skipped")) { for (const r of results.filter((r) => r.status === "skipped")) {
ctx.log("info", ` ${r.fileID}\t${r.title}\t${r.reason}`); ctx.stderr.write(
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
);
} }
} }
if (failed > 0) { if (failed > 0) {
ctx.log("error", "\nFailed files:"); ctx.stderr.write("\nFailed files:\n");
for (const r of results.filter((r) => r.status === "failed")) { for (const r of results.filter((r) => r.status === "failed")) {
ctx.log("error", ` ${r.fileID}\t${r.title}\t${r.reason}`); ctx.stderr.write(
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
);
} }
} }
} }
if (failed === 0) return 0; return results.some((r) => r.status === "failed") ? 1 : 0;
ctx.log(
"fatal",
`quak: ${failed} file(s) failed; see the "Failed files:" list, or the JSON with --json`,
);
return 1;
} finally { } finally {
await lib.close(); await lib.close();
} }
-102
View File
@@ -1,102 +0,0 @@
// The CLI's log level, and the one function the commands write stderr lines
// through.
//
// Every line quak logs goes to stderr, at one of six levels, from the
// quietest: fatal, error, warning, info, debug, trace. A run prints the lines
// at its own level and at every quieter one. Its level is info unless `-v` or
// `-q` asks for another (see "What quak prints" in the README). A command that
// runs and exits non-zero logs a fatal line saying why, so even `-q`, which
// prints only fatal lines, never leaves such an exit unexplained. An error
// line reports something that failed, such as a file or an album, and a
// warning something that did not fail but needs the user's attention. stdout
// carries a command's output and the prompts of `login`, and is not a log.
// Two things on stderr are not log lines: the `error:` message for a
// command line that quak cannot parse, and the usage printed when no command
// is given.
//
// The library itself writes no lines: its layers report through callbacks, and
// the CLI logs what they report. `logRequests` gives the API client options
// that log each request and each retry, and `logRefresh` the library's
// `onProgress` that logs what a refresh and an ML data fetch report.
import type { ApiClientOptions } from "./api/client.js";
import { describeError } from "./errors.js";
import type { RefreshEvent } from "./library/index.js";
import type { RetryOptions } from "./retry.js";
const LEVELS = ["fatal", "error", "warning", "info", "debug", "trace"] as const;
export type LogLevel = (typeof LEVELS)[number];
// Writes `line` on stderr when the run's level prints lines at `level`.
export type Log = (level: LogLevel, line: string) => void;
// The level asked for by `-v` given `verbose` times, or by `-q`: info without
// either, debug for `-v`, trace for `-vv` and more, fatal for `-q`.
// `bin/quak.ts` refuses `-q` and `-v` together.
export const logLevel = (verbose: number, quiet: boolean): LogLevel => {
if (quiet) return "fatal";
if (verbose === 0) return "info";
return verbose === 1 ? "debug" : "trace";
};
// The log that writes each line at `level` or quieter to `stderr`, ending it
// with a newline.
export const stderrLog =
(stderr: { write(text: string): unknown }, level: LogLevel): Log =>
(lineLevel, line) => {
if (LEVELS.indexOf(lineLevel) <= LEVELS.indexOf(level)) {
stderr.write(line + "\n");
}
};
// API client options that log each request at trace, with the status of its
// response or the error it failed with, and each retry at debug, with why and
// how long it waits. `retry` is the retry policy, the default when not given.
export const logRequests = (
log: Log,
retry?: RetryOptions,
): ApiClientOptions => ({
retry: {
...retry,
onRetry: (err, attempt, delayMs) =>
log(
"debug",
`Attempt ${attempt} failed, retrying in ${Math.round(delayMs)} ms: ${describeError(err)}`,
),
},
onRequest: ({ method, url, status, error }) =>
log(
"trace",
`${method} ${url}: ${error === undefined ? `HTTP ${status}` : describeError(error)}`,
),
});
// How a log line names each operation the library reports the start and end
// of.
const OPERATION_NAMES: Record<RefreshEvent["operation"], string> = {
refresh: "Refresh",
fetchMLData: "ML data fetch",
precacheThumbnails: "Thumbnail precache",
precacheOriginals: "Original precache",
};
// A library `onProgress` that logs, at info, each collection whose files a
// refresh has fetched, and at debug the start and end of each refresh and ML
// data fetch, with the error of one that failed.
export const logRefresh =
(log: Log) =>
(event: RefreshEvent): void => {
if (event.status === "progress") {
log(
"info",
`Fetched the files of ${event.fetched} of ${event.total} collections`,
);
return;
}
const error = event.error === undefined ? "" : `: ${event.error}`;
log(
"debug",
`${OPERATION_NAMES[event.operation]} ${event.status}${error}`,
);
};
+19 -8
View File
@@ -9,7 +9,11 @@
// `metadata.title`, and issue #52 requires that output stay byte-identical, so // `metadata.title`, and issue #52 requires that output stay byte-identical, so
// the commands shape their output from the raw `EnteFile` through here. // the commands shape their output from the raw `EnteFile` through here.
import { defaultFileName } from "./filename.js"; import {
MAX_NAME_BYTES,
sanitizeFileName,
shortenKeepingExtension,
} from "./filename.js";
import type { EnteFile, FileType, Microseconds } from "./model/types.js"; import type { EnteFile, FileType, Microseconds } from "./model/types.js";
// One row of `quak files --json`. // One row of `quak files --json`.
@@ -33,13 +37,20 @@ export const fileListRow = (file: EnteFile): FileListRow => ({
export const fileListLine = (file: EnteFile): string => export const fileListLine = (file: EnteFile): string =>
`${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`; `${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`;
// Default output path for `quak get` when `--out` is not given, made from the // Default output path for `quak get` when `--out` is not given. The title comes
// title (see `defaultFileName`), with `ext` in place of its extension for a // from the server, so it is sanitized, and cut short to fit in a file name,
// live photo's image or video; `--out` is the user's and is used as is. // keeping its extension; `--out` is the user's and is used as is.
export const originalName = (file: EnteFile, ext?: string): string => export const originalName = (file: EnteFile): string =>
defaultFileName(file.metadata.title, file.id, { ext }); shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES,
);
// Default output path for `quak get-thumb` when `--out` is not given: `thumb_` // Default output path for `quak get-thumb` when `--out` is not given: `thumb_`
// and the title. // and the title, cut short so that the two fit in a file name.
export const thumbnailName = (file: EnteFile): string => export const thumbnailName = (file: EnteFile): string =>
defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" }); "thumb_" +
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES - "thumb_".length,
);
+38 -126
View File
@@ -1,150 +1,62 @@
// How the CLI's read commands obtain current data. // How the CLI's read commands obtain current data.
// //
// `collections`, `files`, `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). `get`, // local cache last happened to hold (owner amendment, issue #36). Each helper
// `get-thumb` and `files` without a collection ID therefore force a server // therefore forces a server round-trip through `Library.fresh()` and only then
// round-trip through `Library.fresh()` and only then read, so a change made // reads — so a collection, file, or metadata change made elsewhere is visible.
// 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. `files` with a collection ID reads that
// list too, then that one collection's files through `Library.freshFiles()`
// (issue #217). On an empty cache, `get` and `get-thumb` read that list, then
// one collection's files at a time, until they find the file (issue #222).
// `helper fix-missing-thumbnails --file` does the same until it finds every
// file it names (issue #223).
// //
// `collections` and `files` with a collection ID list in the server's order. // `collections` and `files` also list in the library's own enumeration order —
// `files` without one lists in the library's own enumeration order // `listCollections()`/`listFiles()`, the order the pre-library CLI printed —
// (`listFiles()`). Neither command uses the `albums`/`photos` projection's // rather than the `albums`/`photos` projection's newest-first order, which
// newest-first order, which re-sorts the rows. The field values still come // re-sorts the rows. The field values still come from each record's raw
// from each record's raw metadata via `cli-output.ts`. `files` without a // metadata via `cli-output.ts`.
// collection ID lists every file in the account once (issue #211): the library
// keeps a record of a file for each collection that holds it, and only the
// first collection's record is listed.
import type { Collection, EnteFile } from "./model/types.js"; import type { Collection, EnteFile } from "./model/types.js";
import type { Photo, PhotosAPI } from "./library/index.js";
// The slice of `Library` these helpers read. `Library` satisfies it // The slice of `Library` these helpers read. `Library` satisfies it
// structurally; a test can drive them with a stand-in that records each fresh // structurally; a test can drive them with a stand-in that records the
// read and serves records in a known enumeration order. // `fresh()` call and serves records in a known enumeration order.
export interface FreshReadLibrary { export interface FreshReadLibrary {
fresh(): Promise<unknown>; fresh(): Promise<unknown>;
freshCollections(): Promise<Collection[]>;
freshFiles(collection: Collection): Promise<EnteFile[]>;
listCollections(): Collection[]; listCollections(): Collection[];
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">;
} }
// Every live collection, current as of one collections request, in the // Every live collection, current as of a forced refresh, in enumeration order.
// server's order. export const freshCollections = async (
export const freshCollections = (
lib: FreshReadLibrary, lib: FreshReadLibrary,
): Promise<Collection[]> => lib.freshCollections(); ): Promise<Collection[]> => {
// The files of one collection, as the server holds them now, in the server's
// order. `undefined` (not an empty list) when the collection does not exist, so
// the caller can tell "no such collection" from "an empty collection". Without
// a collection ID, every file in the account as of a forced refresh, each once,
// as held by the first collection that holds it.
export const freshFiles = async (
lib: FreshReadLibrary,
collectionID?: number,
): Promise<EnteFile[] | undefined> => {
if (collectionID !== undefined) {
const collections = await lib.freshCollections();
const collection = collections.find((c) => c.id === collectionID);
return collection ? lib.freshFiles(collection) : undefined;
}
await lib.fresh(); await lib.fresh();
const listed = new Set<number>(); return lib.listCollections();
const files: EnteFile[] = [];
for (const collection of lib.listCollections()) {
for (const file of lib.listFiles(collection.id)) {
if (listed.has(file.id)) continue;
listed.add(file.id);
files.push(file);
}
}
return files;
}; };
// One file as the server holds it now, or `undefined` when no collection holds // The files of one collection, current as of a forced refresh, in enumeration
// it. When the cache holds the account's collections, it refreshes and looks // order. `undefined` (not an empty list) when the collection does not exist, so
// the file up in the cache. On an empty cache, it reads the collection list, // the caller can tell "no such collection" from "an empty collection".
// then one collection's files at a time, and stops at the first collection export const freshFiles = async (
// that holds the file. That search stores nothing. lib: FreshReadLibrary,
collectionID: number,
): Promise<EnteFile[] | undefined> => {
await lib.fresh();
if (!lib.getCollection(collectionID)) return undefined;
return lib.listFiles(collectionID);
};
// One file, current as of a forced refresh, resolved to both its content
// handle (`Photo`, for fetching bytes) and its raw record (`EnteFile`, for the
// default output name and field values). `undefined` when the file is unknown.
export const freshFile = async ( export const freshFile = async (
lib: FreshReadLibrary, lib: FreshReadLibrary,
fileID: number, fileID: number,
): Promise<EnteFile | undefined> => { ): Promise<{ photo: Photo; file: EnteFile } | undefined> => {
// Only a refresh stores collections, so a cache holding any holds them all.
if (lib.listCollections().length > 0) {
await lib.fresh(); await lib.fresh();
return lib.getFileByID(fileID); const photo = lib.photos.byID({ fileID });
} const file = lib.getFileByID(fileID);
for (const collection of await lib.freshCollections()) { if (!photo || !file) return undefined;
const files = await lib.freshFiles(collection); return { photo, file };
const file = files.find((f) => f.id === fileID);
if (file) return file;
}
return undefined;
};
// The most recently updated collection first, and the higher ID first on a
// tie. `Library.albums.list()` lists albums in this order.
const byNewestCollection = (a: Collection, b: Collection): number =>
b.updationTime - a.updationTime || b.id - a.id;
// Each file named by `fileIDs` that the cache holds, mapped to its record and
// the collection it was found in. Collections are searched in
// `byNewestCollection` order, and a file is found in the first that holds it.
// So `helper fix-missing-thumbnails` names a file after the same album as
// `helper list-missing-thumbnails`. It reads the cache alone, so the caller
// refreshes first.
export const cachedFilesByID = (
lib: FreshReadLibrary,
fileIDs: number[],
): Map<number, { file: EnteFile; collection: Collection }> => {
const wanted = new Set(fileIDs);
const found = new Map<number, { file: EnteFile; collection: Collection }>();
const collections = [...lib.listCollections()].sort(byNewestCollection);
for (const collection of collections) {
for (const file of lib.listFiles(collection.id)) {
if (wanted.has(file.id) && !found.has(file.id)) {
found.set(file.id, { file, collection });
}
}
}
return found;
};
// Each file named by `fileIDs` as the server holds it now, mapped to its record
// and collection as `cachedFilesByID` maps it. When the cache holds the
// account's collections, it refreshes and looks the files up in the cache. On
// an empty cache, it reads the collection list, then one collection's files at
// a time, and stops once it has found every file. That search stores nothing.
export const freshFilesByID = async (
lib: FreshReadLibrary,
fileIDs: number[],
): Promise<Map<number, { file: EnteFile; collection: Collection }>> => {
// Only a refresh stores collections, so a cache holding any holds them all.
if (lib.listCollections().length > 0) {
await lib.fresh();
return cachedFilesByID(lib, fileIDs);
}
const wanted = new Set(fileIDs);
const found = new Map<number, { file: EnteFile; collection: Collection }>();
const collections = await lib.freshCollections();
for (const collection of [...collections].sort(byNewestCollection)) {
if (found.size === wanted.size) break;
for (const file of await lib.freshFiles(collection)) {
if (wanted.has(file.id) && !found.has(file.id)) {
found.set(file.id, { file, collection });
}
}
}
return found;
}; };
+9 -12
View File
@@ -2,23 +2,21 @@
import type { Writable } from "node:stream"; import type { Writable } from "node:stream";
import { ApiError } from "./api/client.js"; import { ApiError } from "./api/client.js";
import type { Log } from "./cli-log.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
// Run a command and exit with its code once stdout/stderr have drained. // Run a command and exit with its code once stdout/stderr have drained.
// Exiting before the drain can truncate piped output, and the library can keep // Exiting before the drain can truncate piped output, and the library can keep
// the event loop alive after a command returns, so a plain return could hang. // the event loop alive after a command returns, so a plain return could hang.
// An error the command throws is logged as one fatal `quak: MESSAGE` line, // An error the command throws is printed as one `quak: MESSAGE` line, without
// without the stack trace, and exits 1. MESSAGE is the error as // the stack trace, and exits 1. MESSAGE is the error as `describeError` gives
// `describeError` gives it, so it carries the reason Node's `fetch` puts on // it, so it carries the reason Node's `fetch` puts on `cause`. A 401 from the
// `cause`. A 401 from the server means it no longer accepts the saved session: // server means it no longer accepts the saved session: that prints one line
// that logs one fatal line saying to log in again and exits 3, as a missing or // saying to log in again and exits 3, as a missing or corrupt session file
// corrupt session file does. // does.
export const run = async ( export const run = async (
command: Promise<number>, command: Promise<number>,
stdout: Writable, stdout: Writable,
stderr: Writable, stderr: Writable,
log: Log,
exit: (code: number) => void, exit: (code: number) => void,
): Promise<void> => { ): Promise<void> => {
let code: number; let code: number;
@@ -26,13 +24,12 @@ export const run = async (
code = await command; code = await command;
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 401) { if (err instanceof ApiError && err.status === 401) {
log( stderr.write(
"fatal", `quak: the saved session is no longer valid; run "quak login"\n`,
`quak: the saved session is no longer valid; run "quak login"`,
); );
code = 3; code = 3;
} else { } else {
log("fatal", `quak: ${describeError(err)}`); stderr.write(`quak: ${describeError(err)}\n`);
code = 1; code = 1;
} }
} }
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,57 +0,0 @@
// Keywords for a file from its CLIP embedding.
//
// Ente stores no keywords. A file's ML data holds a CLIP embedding, the 512
// numbers Ente's image model gave the photo, and Ente's photos app sorts files
// into the categories of its Discover section with it: a file falls in a
// category when the cosine similarity of its CLIP embedding and the text
// embedding of the category's query is at least the category's `minScore`,
// unless the file is a video and the category leaves out videos. A file's
// keywords are the titles of the categories it falls in. The categories and
// their text embeddings are in `clip-keyword-vectors.ts`.
import { DISCOVER_CATEGORIES } from "./clip-keyword-vectors.js";
import { clipEmbedding } from "./library/mldata.js";
import type { MLData } from "./mldata-fetch.js";
// How many numbers a CLIP embedding from Ente's image model has.
const EMBEDDING_LENGTH = 512;
const norm = (vector: number[]): number => Math.hypot(...vector);
const cosineSimilarity = (a: number[], b: number[]): number => {
let dot = 0;
a.forEach((value, i) => {
dot += value * (b[i] ?? 0);
});
return dot / (norm(a) * norm(b));
};
// The titles of the Discover categories a file falls in, highest score first,
// or undefined when its ML data has no CLIP embedding of 512 finite numbers
// that are not all zero. Tests pass their own `categories`.
export const clipKeywords = (
mlData: MLData,
isVideo: boolean,
categories = DISCOVER_CATEGORIES,
): string[] | undefined => {
const embedding = clipEmbedding(mlData);
if (
!embedding ||
embedding.length !== EMBEDDING_LENGTH ||
norm(embedding) === 0
) {
return undefined;
}
const matches: { title: string; score: number }[] = [];
for (const category of categories) {
if (isVideo && !category.showVideo) continue;
const score = cosineSimilarity(embedding, category.textEmbedding);
if (score >= category.minScore) {
matches.push({ title: category.title, score });
}
}
return matches
.sort((a, b) => b.score - a.score)
.map((match) => match.title);
};
+26 -27
View File
@@ -16,7 +16,13 @@ import {
streamTagFinal, streamTagFinal,
} from "../crypto/index.js"; } from "../crypto/index.js";
import { TruncatedStreamError } from "../errors.js"; import { TruncatedStreamError } from "../errors.js";
import { defaultFileName, safeExtension, withExtension } from "../filename.js"; import {
MAX_NAME_BYTES,
safeExtension,
sanitizeFileName,
shortenKeepingExtension,
withExtension,
} from "../filename.js";
import { withRetry } from "../retry.js"; import { withRetry } from "../retry.js";
import type { ApiClient } from "../api/client.js"; import type { ApiClient } from "../api/client.js";
import type { EnteFile } from "../model/types.js"; import type { EnteFile } from "../model/types.js";
@@ -365,14 +371,10 @@ const openPart = async (
// name starts with `image` as the image and the one whose name starts with // name starts with `image` as the image and the one whose name starts with
// `video` as the video, and refuses a ZIP holding a second of either. It is // `video` as the video, and refuses a ZIP holding a second of either. It is
// written unpacked: each part is named `destination` with the extension // written unpacked: each part is named `destination` with the extension
// replaced by its own entry's, and the two must differ ignoring case. When // replaced by its own entry's, and the two must differ ignoring case. When the
// `defaultName` is set, `destination` is the name `defaultFileName` made from // file records a hash, `<imageHash>:<videoHash>` must match it, each over that
// the title rather than a path the caller gave, and each part is named as // part's own bytes. Only then are the image, then the video, renamed into
// `defaultFileName` names it with its own extension, so that the name is cut // place; on any failure neither is stored.
// short to fit with that extension. When the file records a hash,
// `<imageHash>:<videoHash>` must match it, each over that part's own bytes.
// Only then are the image, then the video, renamed into place; on any failure
// neither is stored.
// //
// The ZIP is chosen by its uploader and may expand enormously, so each part is // The ZIP is chosen by its uploader and may expand enormously, so each part is
// written as it decompresses and never held, and the ZIP is refused once the // written as it decompresses and never held, and the ZIP is refused once the
@@ -392,7 +394,6 @@ const decryptLivePhoto = async (
key: Uint8Array, key: Uint8Array,
onProgress: ProgressCallback | undefined, onProgress: ProgressCallback | undefined,
file: EnteFile, file: EnteFile,
defaultName: boolean,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
const sliceSize = 4096; const sliceSize = 4096;
const dir = dirname(destination); const dir = dirname(destination);
@@ -491,12 +492,8 @@ const decryptLivePhoto = async (
`live photo's image and video have the same extension, ${video.ext}`, `live photo's image and video have the same extension, ${video.ext}`,
); );
} }
const partPath = (ext: string): string => const path = withExtension(destination, image.ext);
defaultName const videoPath = withExtension(destination, video.ext);
? defaultFileName(file.metadata.title, file.id, { ext })
: withExtension(destination, ext);
const path = partPath(image.ext);
const videoPath = partPath(video.ext);
for (const part of parts) { for (const part of parts) {
await part.handle.sync(); await part.handle.sync();
await part.handle.close(); await part.handle.close();
@@ -540,9 +537,6 @@ const decryptLivePhoto = async (
// and then removes its own temp file, and only the attempt that reaches // and then removes its own temp file, and only the attempt that reaches
// TAG_FINAL renames one into place, so a download that needed three tries still // TAG_FINAL renames one into place, so a download that needed three tries still
// performs exactly one rename over the destination. // performs exactly one rename over the destination.
//
// `defaultName` says `destination` is the name `defaultFileName` made, not a
// path the caller gave (see `decryptLivePhoto`).
const fetchAndDecrypt = async ( const fetchAndDecrypt = async (
api: ApiClient, api: ApiClient,
openStream: () => Promise<ReadableStream<Uint8Array>>, openStream: () => Promise<ReadableStream<Uint8Array>>,
@@ -551,7 +545,6 @@ const fetchAndDecrypt = async (
destination: string, destination: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
original?: EnteFile, original?: EnteFile,
defaultName = false,
): Promise<DownloadResult> => ): Promise<DownloadResult> =>
withRetry(async () => { withRetry(async () => {
const stream = await openStream(); const stream = await openStream();
@@ -564,7 +557,6 @@ const fetchAndDecrypt = async (
key, key,
onProgress, onProgress,
original, original,
defaultName,
); );
} }
const bytesWritten = await decryptToTemp( const bytesWritten = await decryptToTemp(
@@ -596,11 +588,15 @@ export const downloadFile = async (
outPath?: string, outPath?: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
// `outPath` is the caller's and is used as is. Without it, the file is // `outPath` is the caller's and is used as is; the title is the server's
// written in the current directory under the name `defaultFileName` makes // and is sanitized so it can only name a file in the current directory,
// from the title, which is the server's. // and cut short to fit in a file name, keeping its extension.
const resolvedPath = const resolvedPath =
outPath ?? defaultFileName(file.metadata.title, file.id); outPath ??
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES,
);
const header = fromBase64(file.file.decryptionHeader); const header = fromBase64(file.file.decryptionHeader);
return fetchAndDecrypt( return fetchAndDecrypt(
api, api,
@@ -610,7 +606,6 @@ export const downloadFile = async (
resolvedPath, resolvedPath,
onProgress, onProgress,
file, file,
outPath === undefined,
); );
}; };
@@ -623,7 +618,11 @@ export const downloadThumbnail = async (
// As in `downloadFile`, with `thumb_` before the title. // As in `downloadFile`, with `thumb_` before the title.
const resolvedPath = const resolvedPath =
outPath ?? outPath ??
defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" }); "thumb_" +
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES - "thumb_".length,
);
const header = fromBase64(file.thumbnail.decryptionHeader); const header = fromBase64(file.thumbnail.decryptionHeader);
return fetchAndDecrypt( return fetchAndDecrypt(
api, api,
+5 -38
View File
@@ -6,8 +6,6 @@
// directory the caller chose: never a path, never `..`, never hidden, never a // directory the caller chose: never a path, never `..`, never hidden, never a
// Windows device name. Such a name can still be too long for a file name, and // Windows device name. Such a name can still be too long for a file name, and
// `shortenToBytes` or `shortenKeepingExtension` cuts one short. // `shortenToBytes` or `shortenKeepingExtension` cuts one short.
// `defaultFileName` does both for the name a file is saved under when the
// caller gives no path.
// //
// A path the user typed (`--out`, `outPath`) is not passed through here: the // A path the user typed (`--out`, `outPath`) is not passed through here: the
// caller is trusted, the server is not. // caller is trusted, the server is not.
@@ -63,12 +61,10 @@ export const shortenToBytes = (name: string, maxBytes: number): string => {
// `name` cut short to fit in `maxBytes` bytes with `insert` put right before // `name` cut short to fit in `maxBytes` bytes with `insert` put right before
// its extension (`IMG_0001 (12).jpg`), each cut made as `shortenToBytes` // its extension (`IMG_0001 (12).jpg`), each cut made as `shortenToBytes`
// makes it. The part before the extension is cut first, so the extension is // makes it. The part before the extension is cut first, so the extension is
// kept whenever it fits, but that part always keeps its first character: // kept whenever it fits. When the extension alone leaves no room for
// when the extension leaves no room for `insert` and that character, the // `insert`, the extension is cut short too, and the part before it keeps only
// extension is cut short too, and the part before it keeps the bytes that cut // the bytes that cut leaves over. `insert` is never cut. With no `insert`, a
// leaves over. So a name that does not start with a dot never comes back // name that fits comes back unchanged.
// starting with one, which would hide it. `insert` is never cut. With no
// `insert`, a name that fits comes back unchanged.
export const shortenKeepingExtension = ( export const shortenKeepingExtension = (
name: string, name: string,
maxBytes: number, maxBytes: number,
@@ -76,36 +72,7 @@ export const shortenKeepingExtension = (
): string => { ): string => {
const fullExt = extname(name); const fullExt = extname(name);
const stem = name.slice(0, name.length - fullExt.length); const stem = name.slice(0, name.length - fullExt.length);
const first = Array.from(stem)[0] ?? ""; const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(insert));
const ext = shortenToBytes(
fullExt,
maxBytes - Buffer.byteLength(insert + first),
);
const room = maxBytes - Buffer.byteLength(insert + ext); const room = maxBytes - Buffer.byteLength(insert + ext);
return shortenToBytes(stem, room) + insert + ext; return shortenToBytes(stem, room) + insert + ext;
}; };
// The name a file is saved under in the working directory when the caller
// gives no path (`quak get` without `--out`, `downloadFile` without
// `outPath`): `prefix` (`thumb_` for a thumbnail), then the file's title with
// unsafe characters replaced, or `file-<id>` when the title is empty, cut
// short so that the whole fits in a file name, keeping the title's extension
// whenever it fits. `ext` (".mov"), when given, takes the place of the
// title's extension before the name is cut short; a live photo's image and
// video are each named with their own. The cut can leave a device name
// (`CONSOLE.<long extension>` becomes `CON.<long extension>`); that name gets
// a leading `_`, as `sanitizeFileName` gives one, and is cut a byte shorter to
// leave room for it.
export const defaultFileName = (
title: string,
fileID: number,
opts: { prefix?: string; ext?: string } = {},
): string => {
const { prefix = "", ext } = opts;
const safe = sanitizeFileName(title, `file-${fileID}`);
const name = ext === undefined ? safe : withExtension(safe, ext);
const room = MAX_NAME_BYTES - Buffer.byteLength(prefix);
const cut = prefix + shortenKeepingExtension(name, room);
if (!RESERVED_DEVICE_NAME.test(cut)) return cut;
return `_${prefix}${shortenKeepingExtension(name, room - 1)}`;
};
+11 -38
View File
@@ -581,34 +581,14 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
fileID: number, fileID: number,
opts?: ContentOptions, opts?: ContentOptions,
): Promise<ContentResult> { ): Promise<ContentResult> {
const file = this.fileOrThrow(fileID); return this.get(fileID, "original", "on-demand", opts?.onProgress);
return this.get(file, "original", "on-demand", opts?.onProgress);
} }
async thumbnail( async thumbnail(
fileID: number, fileID: number,
opts?: ContentOptions, opts?: ContentOptions,
): Promise<ContentResult> { ): Promise<ContentResult> {
const file = this.fileOrThrow(fileID); return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
return this.get(file, "thumbnail", "on-demand", opts?.onProgress);
}
// The original of `file`, fetched and cached as `original` does. `file` is
// used as given, so `getFile` need not know it.
async originalOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
return this.get(file, "original", "on-demand", opts?.onProgress);
}
// The thumbnail of `file`, fetched and cached as `thumbnail` does. `file`
// is used as given, so `getFile` need not know it.
async thumbnailOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
return this.get(file, "thumbnail", "on-demand", opts?.onProgress);
} }
// Put the original at the save path of `file` under the download directory // Put the original at the save path of `file` under the download directory
@@ -635,7 +615,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
destination: string, destination: string,
): Promise<ContentResult> { ): Promise<ContentResult> {
const result = await this.acquire( const result = await this.acquire(
this.fileOrThrow(fileID), fileID,
"original", "original",
"on-demand", "on-demand",
undefined, undefined,
@@ -658,7 +638,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
fileID: number, fileID: number,
destination: string, destination: string,
): Promise<{ path: string; videoPath?: string }> { ): Promise<{ path: string; videoPath?: string }> {
const file = this.fileOrThrow(fileID); const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
return this.pools.content.run( return this.pools.content.run(
async () => { async () => {
const fetched = await this.fetchInto( const fetched = await this.fetchInto(
@@ -740,8 +721,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
onProgress: ((event: EnsureEvent) => void) | undefined, onProgress: ((event: EnsureEvent) => void) | undefined,
): Promise<EnsureResult> { ): Promise<EnsureResult> {
try { try {
const file = this.fileOrThrow(fileID); const result = await this.acquire(fileID, kind, priority, signal);
const result = await this.acquire(file, kind, priority, signal);
const status = result.cached ? "skipped" : "done"; const status = result.cached ? "skipped" : "done";
onProgress?.({ fileID, status, path: result.path }); onProgress?.({ fileID, status, path: result.path });
return { fileID, path: result.path }; return { fileID, path: result.path };
@@ -756,16 +736,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
} }
} }
// The record `getFile` gives for `fileID`. It throws for a file `getFile`
// does not know.
private fileOrThrow(fileID: number): EnteFile {
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
return file;
}
private async get( private async get(
file: EnteFile, fileID: number,
kind: Kind, kind: Kind,
priority: Priority, priority: Priority,
onProgress: ((event: ContentEvent) => void) | undefined, onProgress: ((event: ContentEvent) => void) | undefined,
@@ -773,7 +745,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
const onByte: ProgressCallback | undefined = onProgress const onByte: ProgressCallback | undefined = onProgress
? (bytesDone) => onProgress({ status: "downloading", bytesDone }) ? (bytesDone) => onProgress({ status: "downloading", bytesDone })
: undefined; : undefined;
const result = await this.acquire(file, kind, priority, undefined, { const result = await this.acquire(fileID, kind, priority, undefined, {
onByte, onByte,
}); });
onProgress?.( onProgress?.(
@@ -795,7 +767,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
// photo's original is present only with its video, and is returned with // photo's original is present only with its video, and is returned with
// it. // it.
private async acquire( private async acquire(
file: EnteFile, fileID: number,
kind: Kind, kind: Kind,
priority: Priority, priority: Priority,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
@@ -806,7 +778,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
videoPath?: string; videoPath?: string;
cached: boolean; cached: boolean;
}> { }> {
const fileID = file.id; const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
const isLivePhoto = const isLivePhoto =
kind === "original" && file.metadata.fileType === "livePhoto"; kind === "original" && file.metadata.fileType === "livePhoto";
+10 -107
View File
@@ -18,17 +18,6 @@
// 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. `freshFiles()`
// is the fresh read of one collection's files, and stores nothing either
// (issue #217). 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
@@ -66,8 +55,6 @@ import {
ContentCache, ContentCache,
savePath, savePath,
storedAtSavePath, storedAtSavePath,
type ContentOptions,
type ContentResult,
type ContentSource, type ContentSource,
type ThumbnailsAPI, type ThumbnailsAPI,
type EnsureOptions, type EnsureOptions,
@@ -174,21 +161,18 @@ export interface LibraryClient {
// 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 and, in `mlDataErrors`, // 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. // 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 // The precache fills ("precacheThumbnails"/"precacheOriginals", #48) fire
// fetched: `fetched` is how many collections it has fetched the files of so // "started"/"done" around each sweep that has work, "done" reporting the count
// far, `total` how many it will. The precache fills // newly cached.
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
// each sweep that has work, "done" reporting the count newly cached.
export interface RefreshEvent { export interface RefreshEvent {
operation: operation:
| "refresh" | "refresh"
| "fetchMLData" | "fetchMLData"
| "precacheThumbnails" | "precacheThumbnails"
| "precacheOriginals"; | "precacheOriginals";
status: "started" | "progress" | "done" | "failed"; status: "started" | "done" | "failed";
error?: string; error?: string;
fetched?: number; fetched?: number;
total?: number;
mlDataErrors?: { fileID: number; error: string }[]; mlDataErrors?: { fileID: number; error: string }[];
} }
@@ -208,10 +192,6 @@ 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
@@ -402,8 +382,7 @@ 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. With `refreshOnOpen: false` there is no first // not block opening.
// 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 =
@@ -506,10 +485,7 @@ export class Library {
// refresh. // refresh.
precache?.start(); precache?.start();
if (opts.refreshOnOpen === false) { if (store.loadedFromDisk) {
// 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());
@@ -613,69 +589,6 @@ 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;
}
// The fresh read of one collection's files (issue #217): every live file
// in it that decrypts, in the server's order. `collection` is a record
// `freshCollections()` returned, whose key decrypts the files. It fetches
// no other collection's files. Like `freshCollections()`, it stores
// nothing. It returns each file once, as the latest copy the server sent,
// where the first copy was.
async freshFiles(collection: Collection): Promise<EnteFile[]> {
if (this.closed) throw new Error("the library is closed");
const { files } = await this.client.filesSince({
collectionID: collection.id,
collectionKey: collection.key,
sinceTime: 0,
});
const byID = new Map<number, EnteFile>();
for (const file of files) byID.set(file.id, file);
return [...byID.values()];
}
// The original of `file`, fetched and cached as `Photo.original()` does.
// `file` is used as given, so the store need not hold it. On an empty
// cache, `quak get` passes a file that `freshFiles()` returned.
async originalOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
if (!this.cache) {
throw new Error(
"originalOf requires a library opened with a content cache",
);
}
return this.cache.originalOf(file, opts);
}
// The thumbnail of `file`, fetched and cached as `Photo.thumbnail()` does.
// `file` is used as given, so the store need not hold it. On an empty
// cache, `quak get-thumb` passes a file that `freshFiles()` returned.
async thumbnailOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
if (!this.cache) {
throw new Error(
"thumbnailOf requires a library opened with a content cache",
);
}
return this.cache.thumbnailOf(file, opts);
}
// Back up every in-scope file to `opts.downloadDirectory`, or else the // Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure // library's, each original at its save path, with a durable failure
// ledger (issue #51). Takes the lock in that directory first, unless the // ledger (issue #51). Takes the lock in that directory first, unless the
@@ -829,28 +742,18 @@ 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. Each collection // collections enumerate from the beginning of time.
// 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 changedCollections) { for (const collection of page.collections) {
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
+1 -1
View File
@@ -61,7 +61,7 @@ export interface MLIndex {
// Pull the CLIP embedding out of a payload, or undefined when it is absent or // Pull the CLIP embedding out of a payload, or undefined when it is absent or
// misshapen. Kept strict so a bad payload is skipped rather than corrupting the // misshapen. Kept strict so a bad payload is skipped rather than corrupting the
// packed index. // packed index.
export const clipEmbedding = (payload: MLData): number[] | undefined => { const clipEmbedding = (payload: MLData): number[] | undefined => {
const clip = payload.clip; const clip = payload.clip;
if (typeof clip !== "object" || clip === null) return undefined; if (typeof clip !== "object" || clip === null) return undefined;
const embedding = (clip as { embedding?: unknown }).embedding; const embedding = (clip as { embedding?: unknown }).embedding;
+4 -21
View File
@@ -1,7 +1,6 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import * as jpeg from "jpeg-js"; import * as jpeg from "jpeg-js";
import { clipKeywords } from "./clip-keywords.js";
import type { Client } from "./client.js"; import type { Client } from "./client.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
import { readExifTags } from "./exif.js"; import { readExifTags } from "./exif.js";
@@ -18,10 +17,7 @@ import {
} from "./mldata-fetch.js"; } from "./mldata-fetch.js";
import type { EnteFile } from "./model/types.js"; import type { EnteFile } from "./model/types.js";
// Reports one line of the dump's progress. `level` is "error" when the line export type ProgressCallback = (message: string) => void;
// reports ML data that could not be read, and is left out for every other
// line.
export type ProgressCallback = (message: string, level?: "error") => void;
export interface MetadataBackupOptions { export interface MetadataBackupOptions {
exif?: boolean; exif?: boolean;
@@ -98,8 +94,7 @@ const extractExif = async (
// Dump every decrypted metadata layer the account holds into a directory tree // Dump every decrypted metadata layer the account holds into a directory tree
// of plain JSON: account, per-collection, and per-file records including the // of plain JSON: account, per-collection, and per-file records including the
// private and public magic metadata and the ML data, with the keywords of its // private and public magic metadata and (by default) the ML data. Collections
// CLIP embedding in place of the embedding (see clip-keywords.ts). Collections
// and files are enumerated from the library's cache, which the caller refreshes // and files are enumerated from the library's cache, which the caller refreshes
// first. Returns how many files have no ML data because their ML data request // first. Returns how many files have no ML data because their ML data request
// failed or their ML data did not decrypt, gunzip or parse; they are still // failed or their ML data did not decrypt, gunzip or parse; they are still
@@ -200,17 +195,13 @@ export const runMetadataBackup = async (
for (const [id, payload] of payloads) mlDataMap.set(id, payload); for (const [id, payload] of payloads) mlDataMap.set(id, payload);
for (const [id, err] of errors) { for (const [id, err] of errors) {
const reason = describeError(err); const reason = describeError(err);
log( log(`ML data of file ${id} could not be read: ${reason}`);
`ML data of file ${id} could not be read: ${reason}`,
"error",
);
mlDataErrors.set(id, 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);
log( log(
`ML data request for ${batch.length} file(s) failed: ${reason}`, `ML data request for ${batch.length} file(s) failed: ${reason}`,
"error",
); );
for (const id of batch) mlDataErrors.set(id, reason); for (const id of batch) mlDataErrors.set(id, reason);
} }
@@ -232,16 +223,8 @@ export const runMetadataBackup = async (
if (file.pubMagicMetadata) if (file.pubMagicMetadata)
fileMeta.pubMagicMetadata = file.pubMagicMetadata; fileMeta.pubMagicMetadata = file.pubMagicMetadata;
// The ML data without the CLIP embedding, and the keywords that
// embedding gives.
const ml = mlDataMap.get(file.id); const ml = mlDataMap.get(file.id);
if (ml) { if (ml) fileMeta.mlData = ml;
const { clip: _clip, ...mlDataWithoutClip } = ml;
fileMeta.mlData = mlDataWithoutClip;
const isVideo = file.metadata.fileType === "video";
const keywords = clipKeywords(ml, isVideo);
if (keywords) fileMeta.keywords = keywords;
}
const mlError = mlDataErrors.get(file.id); const mlError = mlDataErrors.get(file.id);
if (mlError) fileMeta.mlDataError = mlError; if (mlError) fileMeta.mlDataError = mlError;
+1 -8
View File
@@ -24,9 +24,6 @@ export interface RetryOptions {
sleep?: (ms: number) => Promise<void>; sleep?: (ms: number) => Promise<void>;
// Injected so the jitter is reproducible under test. // Injected so the jitter is reproducible under test.
random?: () => number; random?: () => number;
// Called before each wait with the error that failed attempt number
// `attempt` and how long the wait is, for a caller that logs retries.
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
} }
export type ResolvedRetryOptions = Required<RetryOptions>; export type ResolvedRetryOptions = Required<RetryOptions>;
@@ -38,7 +35,6 @@ export const DEFAULT_RETRY_OPTIONS: ResolvedRetryOptions = {
sleep: (ms: number): Promise<void> => sleep: (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms)), new Promise((resolve) => setTimeout(resolve, ms)),
random: Math.random, random: Math.random,
onRetry: () => {},
}; };
// For a run nobody is watching, such as `quak backup` from cron. A request that // For a run nobody is watching, such as `quak backup` from cron. A request that
@@ -59,7 +55,6 @@ export const resolveRetryOptions = (
maxDelayMs: opts?.maxDelayMs ?? DEFAULT_RETRY_OPTIONS.maxDelayMs, maxDelayMs: opts?.maxDelayMs ?? DEFAULT_RETRY_OPTIONS.maxDelayMs,
sleep: opts?.sleep ?? DEFAULT_RETRY_OPTIONS.sleep, sleep: opts?.sleep ?? DEFAULT_RETRY_OPTIONS.sleep,
random: opts?.random ?? DEFAULT_RETRY_OPTIONS.random, random: opts?.random ?? DEFAULT_RETRY_OPTIONS.random,
onRetry: opts?.onRetry ?? DEFAULT_RETRY_OPTIONS.onRetry,
}); });
// Transport failures: the request did not complete, for reasons below HTTP. // Transport failures: the request did not complete, for reasons below HTTP.
@@ -239,9 +234,7 @@ export const withRetry = async <T>(
// and its status intact. // and its status intact.
throw err; throw err;
} }
const delayMs = backoffMs(attempt, policy); await policy.sleep(backoffMs(attempt, policy));
policy.onRetry(err, attempt, delayMs);
await policy.sleep(delayMs);
} }
} }
}; };
+37 -17
View File
@@ -5,7 +5,7 @@ import type { Client } from "./client.js";
import type { Library } from "./library/index.js"; import type { Library } from "./library/index.js";
import { ApiError } from "./api/client.js"; import { ApiError } from "./api/client.js";
import { encryptBlob, toBase64 } from "./crypto/index.js"; import { encryptBlob, toBase64 } from "./crypto/index.js";
import type { Collection, EnteFile } from "./model/types.js"; import type { EnteFile } from "./model/types.js";
// The server refuses a thumbnail larger than the one it already records for the // The server refuses a thumbnail larger than the one it already records for the
// file (`thumbnail.size`, the encrypted size), so these encodings are tried // file (`thumbnail.size`, the encrypted size), so these encodings are tried
@@ -49,10 +49,7 @@ export interface ThumbnailFixResult {
reason?: string; reason?: string;
} }
// Reports one line of progress. `level` is "error" when the line reports a export type ProgressCallback = (message: string) => void;
// file that could not be checked or fixed, and is left out for every other
// line.
export type ProgressCallback = (message: string, level?: "error") => void;
// Enumerate every file the library knows about, newest album first, each file // Enumerate every file the library knows about, newest album first, each file
// once, and report those whose server-side thumbnail is missing. "Missing" is // once, and report those whose server-side thumbnail is missing. "Missing" is
@@ -114,7 +111,6 @@ export const listMissingThumbnails = async (
} else { } else {
log( log(
`[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`, `[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`,
"error",
); );
} }
} }
@@ -226,18 +222,16 @@ const encryptThumbnailWithin = (
return undefined; return undefined;
}; };
// Regenerate and upload a thumbnail for each requested file. `found` holds the // Regenerate and upload a thumbnail for each requested file. Originals are read
// record of each requested file that a collection holds, and the collection it // through the library's content cache (`photo.original()`); the generated
// was found in. Each original is read through the library's content cache, by // thumbnail is JPEG-encoded, encrypted under the file's own key, and registered
// `lib.originalOf(file)`. The new thumbnail is a JPEG, encrypted under the // with the server — the encrypt-and-upload path is unchanged. Each file is
// file's own key. It is uploaded, then registered with the server. Each file is
// resolved to one outcome (fixed / skipped / failed) and a failure on one file // resolved to one outcome (fixed / skipped / failed) and a failure on one file
// never stops the others. // never stops the others.
export const fixMissingThumbnails = async ( export const fixMissingThumbnails = async (
lib: Library, lib: Library,
client: Client, client: Client,
fileIDs: number[], fileIDs: number[],
found: Map<number, { file: EnteFile; collection: Collection }>,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<ThumbnailFixResult[]> => { ): Promise<ThumbnailFixResult[]> => {
const log = onProgress ?? (() => {}); const log = onProgress ?? (() => {});
@@ -245,8 +239,31 @@ export const fixMissingThumbnails = async (
const api = client.getApiClient(); const api = client.getApiClient();
const { userID } = client.whoami(); const { userID } = client.whoami();
// Resolve each requested fileID to its file record and owning album by
// enumerating the library, each file taken from the first album that holds
// it. The raw `EnteFile` carries the per-file key the thumbnail is
// encrypted under, which the projected records deliberately do not.
const wanted = new Set(fileIDs);
const fileMap = new Map<
number,
{ file: EnteFile; collectionName: string }
>();
for (const album of lib.albums.list()) {
for (const photo of album.photos.list()) {
if (!wanted.has(photo.fileID) || fileMap.has(photo.fileID))
continue;
const file = lib.getFile(album.collectionID, photo.fileID);
if (file) {
fileMap.set(photo.fileID, {
file,
collectionName: album.name,
});
}
}
}
for (const fileID of fileIDs) { for (const fileID of fileIDs) {
const entry = found.get(fileID); const entry = fileMap.get(fileID);
if (!entry) { if (!entry) {
results.push({ results.push({
fileID, fileID,
@@ -258,8 +275,7 @@ export const fixMissingThumbnails = async (
continue; continue;
} }
const { file } = entry; const { file, collectionName } = entry;
const collectionName = entry.collection.name;
const title = file.metadata.title; const title = file.metadata.title;
const skipReason = reasonToSkip(file, userID); const skipReason = reasonToSkip(file, userID);
@@ -277,10 +293,15 @@ export const fixMissingThumbnails = async (
const maxSize = file.thumbnail.size!; const maxSize = file.thumbnail.size!;
try { try {
const photo = lib.photos.byID({ fileID });
if (!photo) {
throw new Error("file not present in the library cache");
}
log( log(
`[${collectionName}] Downloading ${title} for thumbnail generation...`, `[${collectionName}] Downloading ${title} for thumbnail generation...`,
); );
const { path } = await lib.originalOf(file); const { path } = await photo.original();
const fileBytes = new Uint8Array(readFileSync(path)); const fileBytes = new Uint8Array(readFileSync(path));
if (!isJpeg(fileBytes)) { if (!isJpeg(fileBytes)) {
@@ -338,7 +359,6 @@ export const fixMissingThumbnails = async (
} catch (err) { } catch (err) {
log( log(
`[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`, `[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`,
"error",
); );
results.push({ results.push({
fileID, fileID,
-62
View File
@@ -1222,65 +1222,3 @@ describe("ApiClient non-idempotent requests", () => {
} }
}); });
}); });
// `onRequest` hears of each request the client sends, as its response arrives
// or as it fails. The CLI logs these with `-vv`. A retried request is reported
// once for each attempt.
describe("ApiClient onRequest", () => {
it("reports each request with its status, or with the error it failed with", async () => {
const refused = new TypeError("fetch failed");
const { fetch } = scriptedFetch(
refused,
textResponse("unavailable", 503),
jsonResponse({ collections: [] }),
jsonResponse({}),
streamResponse(new Uint8Array([1, 2, 3])),
);
const requests: unknown[] = [];
const client = new ApiClient({
fetch,
retry: noWait,
onRequest: (request) => requests.push(request),
});
await client.getJSON("/collections/v2", { sinceTime: 0 });
await client.postJSON("/users/logout", {});
await readAll(await client.getFileStream(7));
const collections = "https://api.ente.io/collections/v2?sinceTime=0";
expect(requests).toEqual([
{ method: "GET", url: collections, error: refused },
{ method: "GET", url: collections, status: 503 },
{ method: "GET", url: collections, status: 200 },
{
method: "POST",
url: "https://api.ente.io/users/logout",
status: 200,
},
{
method: "GET",
url: "https://files.ente.io/?fileID=7",
status: 200,
},
]);
});
it("reports a presigned upload without its query, which holds the upload's signature", async () => {
const { fetch } = scriptedFetch(new Response(null, { status: 200 }));
const requests: unknown[] = [];
const client = new ApiClient({
fetch,
retry: noWait,
onRequest: (request) => requests.push(request),
});
await client.putFile(
"https://s3.example/obj?X-Amz-Signature=abc",
new Uint8Array([1, 2]),
);
expect(requests).toEqual([
{ method: "PUT", url: "https://s3.example/obj", status: 200 },
]);
});
});
+6 -22
View File
@@ -54,7 +54,6 @@ import {
saveSession, saveSession,
type CliContext, type CliContext,
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.js";
import { stderrLog } from "../../src/cli-log.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 { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
@@ -86,17 +85,10 @@ const DAY = "2024/2024-03/2024-03-01";
const TOAST = new TextEncoder().encode("a few seconds of the wedding toast"); const TOAST = new TextEncoder().encode("a few seconds of the wedding toast");
// Ente's ML data for beach.jpg: a face it found, and the photo's CLIP // Ente's ML data for beach.jpg: a face it found, and the photo's CLIP
// embedding, here that of a photo of a sunset (see clip-keywords.test.ts). // embedding.
const ML_DATA = { const ML_DATA = {
face: { faces: [{ faceID: "100_1", score: 0.98 }] }, face: { faces: [{ faceID: "100_1", score: 0.98 }] },
clip: { clip: { embedding: [0.5, 0.6, 0.7] },
embedding: JSON.parse(
readFileSync(
new URL("../clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
).embedding,
},
}; };
// Each file's ID, its owner, its original as uploaded, and the metadata its // Each file's ID, its owner, its original as uploaded, and the metadata its
@@ -311,10 +303,9 @@ const backup = async (
const prompted = async (): Promise<never> => { const prompted = async (): Promise<never> => {
throw new Error("quak backup prompted"); throw new Error("quak backup prompted");
}; };
const log = stderrLog(stderr, "info");
const ctx: CliContext = { const ctx: CliContext = {
stdout, stdout,
log, stderr,
sessionDir, sessionDir,
cacheDir: `${dir}-cache`, cacheDir: `${dir}-cache`,
loadSession: (path) => loadSession: (path) =>
@@ -328,7 +319,7 @@ const backup = async (
promptSecret: prompted, promptSecret: prompted,
}; };
const code = await new Promise<number>((resolve) => { const code = await new Promise<number>((resolve) => {
void run(backupCommand(ctx, dir, opts), stdout, stderr, log, resolve); void run(backupCommand(ctx, dir, opts), stdout, stderr, resolve);
}); });
return { code, stderr: printed, stdout: output }; return { code, stderr: printed, stdout: output };
}; };
@@ -418,7 +409,7 @@ describe("the first backup of the account", () => {
expect(downloads.sort()).toEqual([100, 200, 300]); expect(downloads.sort()).toEqual([100, 200, 300]);
}); });
// The tree under "The backup directory" in the README. Each original is at // The tree "Backup layout" in the README describes. Each original is at
// its save path, `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, with // its save path, `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, with
// the extension of its title; the live photo is its image and its video, // the extension of its title; the live photo is its image and its video,
// each with the extension it had inside the ZIP, and a `.livephoto.json` // each with the extension it had inside the ZIP, and a `.livephoto.json`
@@ -493,10 +484,7 @@ describe("the first backup of the account", () => {
}, },
updationTime: TAKEN, updationTime: TAKEN,
}); });
// The JSON holds the ML data without its CLIP embedding, and the expect(beach.mlData).toEqual(ML_DATA);
// keywords the embedding gives in its place.
expect(beach.mlData).toEqual({ face: ML_DATA.face });
expect(beach.keywords).toEqual(["Sunset"]);
expect(beach.imageMetadata.exif.Make.description).toBe("Canon"); expect(beach.imageMetadata.exif.Make.description).toBe("Canon");
expect(beach.imageMetadata.exif.Model.description).toBe("EOS R5"); expect(beach.imageMetadata.exif.Model.description).toBe("EOS R5");
@@ -507,7 +495,6 @@ describe("the first backup of the account", () => {
metadata: { title: "toast.mp4", fileType: "video" }, metadata: { title: "toast.mp4", fileType: "video" },
}); });
expect(toast).not.toHaveProperty("mlData"); expect(toast).not.toHaveProperty("mlData");
expect(toast).not.toHaveProperty("keywords");
expect(toast).not.toHaveProperty("imageMetadata"); expect(toast).not.toHaveProperty("imageMetadata");
expect(toast).not.toHaveProperty("imageMetadataError"); expect(toast).not.toHaveProperty("imageMetadataError");
@@ -1117,9 +1104,6 @@ describe("a backup whose lock is lost", () => {
expect(code).toBe(1); expect(code).toBe(1);
expect(stderr).toBe( expect(stderr).toBe(
"Starting backup...\n" + "Starting backup...\n" +
"Fetched the files of 1 of 3 collections\n" +
"Fetched the files of 2 of 3 collections\n" +
"Fetched the files of 3 of 3 collections\n" +
"Refreshing library...\n" + "Refreshing library...\n" +
"Fetching original beach.jpg (100)...\n" + "Fetching original beach.jpg (100)...\n" +
"Fetching original IMG_0002.HEIC (300)...\n" + "Fetching original IMG_0002.HEIC (300)...\n" +
+2 -21
View File
@@ -42,7 +42,6 @@ import {
saveSession, saveSession,
type CliContext, type CliContext,
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.js";
import { stderrLog } from "../../src/cli-log.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import { Client, type ClientSnapshot } from "../../src/client.js"; import { Client, type ClientSnapshot } from "../../src/client.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js"; import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
@@ -305,7 +304,7 @@ const context = (ente: FakeEnte): CliContext => {
saveSession(join(root, "session"), ente.snapshot()); saveSession(join(root, "session"), ente.snapshot());
return { return {
stdout, stdout,
log: stderrLog(stderr, "info"), stderr,
sessionDir: join(root, "session"), sessionDir: join(root, "session"),
cacheDir: join(root, "cache"), cacheDir: join(root, "cache"),
loadSession: (path) => loadSession: (path) =>
@@ -371,24 +370,6 @@ describe("quak backup with records it cannot decrypt", () => {
expect(stderr.text).toContain(" Failed: 2\n"); expect(stderr.text).toContain(" Failed: 2\n");
}); });
// The album is an error line. The last line is fatal and counts the files
// and the album, so `-q`, which prints fatal lines only, still says why
// the backup exits 1.
it("logs the album at error, and ends with one fatal line counting the files and the album", async () => {
const ctx = {
...context(brokenAccount()),
log: stderrLog(stderr, "error"),
};
expect(await backupCommand(ctx, backupDir(), {})).toBe(1);
expect(stderr.text).toMatch(
/^FAILED album 2: could not be decrypted: /,
);
expect(stderr.text.split("\n").at(-2)).toBe(
`quak: 2 file(s) and 1 album(s) failed; see ${join(backupDir(), "failures.json")} and the FAILED lines`,
);
});
it("names each album it cannot decrypt in the --json result", async () => { it("names each album it cannot decrypt in the --json result", async () => {
expect(await backup(brokenAccount(), { json: true })).toBe(1); expect(await backup(brokenAccount(), { json: true })).toBe(1);
@@ -567,7 +548,7 @@ describe("the read commands with records quak cannot decrypt", () => {
expect(await collectionsCommand(context(ente), {})).toBe(0); expect(await collectionsCommand(context(ente), {})).toBe(0);
expect(stdout.text).toBe("1\talbum\tTrip\n"); expect(stdout.text).toBe("1\talbum\tTrip\n");
expect(await filesCommand(context(ente), "1", {})).toBe(0); expect(await filesCommand(context(ente), { collection: "1" })).toBe(0);
expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n"); expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n");
}); });
}); });
+18 -111
View File
@@ -52,7 +52,6 @@ import { tmpdir } from "node:os";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { runBackup, type BackupLibrary } from "../../src/backup.js"; import { runBackup, type BackupLibrary } from "../../src/backup.js";
import { DISCOVER_CATEGORIES } from "../../src/clip-keyword-vectors.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import type { ContentSource } from "../../src/library/content.js"; import type { ContentSource } from "../../src/library/content.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js";
@@ -947,19 +946,9 @@ describe("the backup lock", () => {
}); });
}); });
// Also serves ML data: file 100 has `ML_PAYLOAD`, whose CLIP embedding is that // Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none.
// of a photo of a sunset (see clip-keywords.test.ts), and the other two have // While `mlError` is set, every ML data request fails with it.
// none. While `mlError` is set, every ML data request fails with it. const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } };
const SUNSET_EMBEDDING: number[] = JSON.parse(
readFileSync(
new URL("../clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
).embedding;
const ML_PAYLOAD = {
face: { faces: [] },
clip: { embedding: SUNSET_EMBEDDING },
};
class MLClient extends MockClient { class MLClient extends MockClient {
mlError?: string; mlError?: string;
@@ -979,99 +968,16 @@ const fileJSON = (outDir: string, fileID: number): Record<string, unknown> =>
JSON.parse(readFileSync(saved(outDir, `${fileID}.json`), "utf-8")); JSON.parse(readFileSync(saved(outDir, `${fileID}.json`), "utf-8"));
describe("ML data in each file's JSON", () => { describe("ML data in each file's JSON", () => {
// The JSON holds the ML data without its CLIP embedding, and in its place it("writes a file's ML data, and no ML field for a file that has none", async () => {
// the keywords the embedding gives.
it("writes a file's ML data and keywords, and no ML field for a file that has none", async () => {
const lib = await openLibrary(stubSource(), new MLClient()); const lib = await openLibrary(stubSource(), new MLClient());
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(fileJSON(outDir, 100).mlData).toEqual({ face: { faces: [] } }); expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
expect(fileJSON(outDir, 100).keywords).toEqual(["Sunset"]);
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlData"); expect(fileJSON(outDir, 101)).not.toHaveProperty("mlData");
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlDataError"); expect(fileJSON(outDir, 101)).not.toHaveProperty("mlDataError");
expect(fileJSON(outDir, 101)).not.toHaveProperty("keywords");
await lib.close();
});
// A file's ML data can hold faces and no CLIP embedding. Its JSON then has
// the ML data and no keywords.
it("writes no keywords for ML data without a CLIP embedding", async () => {
const faces = { face: { faces: [{ faceID: "101_1", score: 0.9 }] } };
// Gives file 101 ML data holding only faces.
class FacesClient extends MockClient {
async fetchMLData(args: { fileIDs: number[] }): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
const payloads = new Map<number, MLData>();
if (args.fileIDs.includes(101)) payloads.set(101, faces);
return { payloads, errors: new Map() };
}
}
const lib = await openLibrary(stubSource(), new FacesClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(fileJSON(outDir, 101).mlData).toEqual(faces);
expect(fileJSON(outDir, 101)).not.toHaveProperty("keywords");
await lib.close();
});
// Ente's photos app keeps videos out of some Discover categories, Identity
// among them. Here a photo and a video share one CLIP embedding, Identity's
// own text embedding, so the photo gets the keyword Identity and the video
// does not.
it("keeps a video out of a category that leaves out videos", async () => {
const identity = DISCOVER_CATEGORIES.find(
(c) => c.title === "Identity",
)!;
expect(identity.showVideo).toBe(false);
const toast = file(102, 1, "toast.mp4");
const video = {
...toast,
metadata: { ...toast.metadata, fileType: "video" as const },
};
// Serves beach.jpg and the video in the Vacation album, and gives
// each ML data holding only that embedding.
class VideoClient extends MockClient {
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
const files =
args.collectionID === 1
? [file(100, 1, "beach.jpg"), video]
: [];
return { files, deleted: [], cursor: 1 };
}
async fetchMLData(args: { fileIDs: number[] }): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
const payloads = new Map<number, MLData>();
for (const id of args.fileIDs) {
payloads.set(id, {
clip: { embedding: identity.textEmbedding },
});
}
return { payloads, errors: new Map() };
}
}
const lib = await openLibrary(stubSource(), new VideoClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(fileJSON(outDir, 100).keywords).toContain("Identity");
expect(fileJSON(outDir, 102)).toHaveProperty("keywords");
expect(fileJSON(outDir, 102).keywords).not.toContain("Identity");
await lib.close(); await lib.close();
}); });
@@ -1087,7 +993,7 @@ describe("ML data in each file's JSON", () => {
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
// File 100's ML data was cached by the first run and is kept. // File 100's ML data was cached by the first run and is kept.
expect(fileJSON(outDir, 100).mlData).toEqual({ face: { faces: [] } }); expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
expect(fileJSON(outDir, 100)).not.toHaveProperty("mlDataError"); expect(fileJSON(outDir, 100)).not.toHaveProperty("mlDataError");
for (const fileID of [101, 200]) { for (const fileID of [101, 200]) {
expect(fileJSON(outDir, fileID).mlDataError).toBe( expect(fileJSON(outDir, fileID).mlDataError).toBe(
@@ -1120,7 +1026,7 @@ describe("ML data in each file's JSON", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(result.skipped).toBe(3); expect(result.skipped).toBe(3);
expect(fileJSON(outDir, 100).mlData).toEqual({ face: { faces: [] } }); expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
for (const fileID of [100, 101, 200]) { for (const fileID of [100, 101, 200]) {
expect(fileJSON(outDir, fileID)).not.toHaveProperty("mlDataError"); expect(fileJSON(outDir, fileID)).not.toHaveProperty("mlDataError");
} }
@@ -1889,11 +1795,12 @@ describe("backup album folders", () => {
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 }); expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 });
}); });
// The extensions of these titles leave no room for the ID and the "a" // The extensions of these titles leave no room for the ID, so each is cut
// before them, so each is cut short to 250 bytes, which leaves one, for // short and the name before the ID keeps only the bytes that cut leaves
// the "a". File 1's extension is 303 bytes and file 2's 271. Neither // over. File 1's extension is 303 bytes and is cut to 251, which leaves
// extension is only letters and digits, so both originals are saved with // none. File 2's is 271 bytes and is cut to 250, since "€" takes three
// `.bin`, a name that fits. // bytes, which leaves one, for the "a". Neither extension is only letters
// and digits, so both originals are saved with `.bin`, a name that fits.
it("cuts the extension of a title short when it leaves no room for the ID", async () => { it("cuts the extension of a title short when it leaves no room for the ID", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const lib = libraryOf([ const lib = libraryOf([
@@ -1908,7 +1815,7 @@ describe("backup album folders", () => {
const result = await runBackup(lib, { downloadDirectory: outDir }); const result = await runBackup(lib, { downloadDirectory: outDir });
const link = `a (1).${"L".repeat(249)}`; const link = ` (1).${"L".repeat(250)}`;
const link2 = `a (2).${"€".repeat(83)}`; const link2 = `a (2).${"€".repeat(83)}`;
expect(Buffer.byteLength(link)).toBe(255); expect(Buffer.byteLength(link)).toBe(255);
expect(Buffer.byteLength(link2)).toBe(255); expect(Buffer.byteLength(link2)).toBe(255);
@@ -1923,9 +1830,9 @@ describe("backup album folders", () => {
// Files 1 and 3 share a title, so each gets its ID before the // Files 1 and 3 share a title, so each gets its ID before the
// extension. File 2's title is too long, and its extension leaves no // extension. File 2's title is too long, and its extension leaves no
// room for the ID and the "x" before it. Were the ID put at the end of // room for the ID. Were the ID put at the end of file 2's title cut
// file 2's title cut short, its link would have file 1's name, and the // short, its link would have file 1's name, and the link made second
// link made second would replace the first. // would replace the first.
it("keeps names unique when a title's extension leaves no room for the ID", async () => { it("keeps names unique when a title's extension leaves no room for the ID", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const shared = `x.${"Q".repeat(245)} (2)`; const shared = `x.${"Q".repeat(245)} (2)`;
@@ -1945,8 +1852,8 @@ describe("backup album folders", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([ expect(tree(outDir)).toEqual([
"Trip/", "Trip/",
`Trip/ (2).${"Q".repeat(250)} -> ${linkTo("2.bin")}`,
`Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`, `Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`,
`Trip/x (2).${"Q".repeat(249)} -> ${linkTo("2.bin")}`,
`Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`, `Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`,
"Trip.json", "Trip.json",
]); ]);
+8 -34
View File
@@ -1,8 +1,6 @@
/** /**
* Tests for the retry options `bin/quak.ts` loads the saved session with * Tests for the retry options `bin/quak.ts` loads the saved session with:
* (`quak backup` gets the unattended ones, every other command the default), * `quak backup` gets the unattended ones, every other command the default.
* each with the callbacks that log its requests and retries, and for
* `quak files` taking its collection ID as an optional argument.
* *
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader * Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
* replaced by one that records the options it is given and reports no session, * replaced by one that records the options it is given and reports no session,
@@ -28,19 +26,14 @@ vi.mock("../../src/cli-session.js", () => ({
const argv = process.argv; const argv = process.argv;
const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-")); const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-"));
// Run `quak <args>` to completion and return what it wrote on stderr, which is // Run `quak <args>` to completion. The "Not logged in" message and the exit
// kept off the terminal. The exit is swallowed. // are swallowed.
const quak = async (...args: string[]): Promise<string> => { const quak = async (...args: string[]): Promise<void> => {
let written = ""; vi.spyOn(process.stderr, "write").mockImplementation(() => true);
vi.spyOn(process.stderr, "write").mockImplementation((text) => {
written += String(text);
return true;
});
vi.spyOn(process, "exit").mockImplementation(() => undefined as never); vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
process.argv = ["node", "quak", ...args]; process.argv = ["node", "quak", ...args];
vi.resetModules(); vi.resetModules();
await import("../../bin/quak.js"); await import("../../bin/quak.js");
return written;
}; };
afterEach(() => { afterEach(() => {
@@ -66,32 +59,13 @@ describe("bin/quak.ts session loading", () => {
attempts, attempts,
baseDelayMs, baseDelayMs,
maxDelayMs, maxDelayMs,
onRetry: expect.any(Function),
}), }),
onRequest: expect.any(Function),
}, },
]); ]);
}); });
it("loads the session for another command with the default retry options", async () => { it("loads the session for another command with the default options", async () => {
await quak("collections"); await quak("collections");
expect(loaded).toEqual([ expect(loaded).toEqual([undefined]);
{
retry: { onRetry: expect.any(Function) },
onRequest: expect.any(Function),
},
]);
});
});
// The command line takes `files` with or without a collection ID, and the
// command runs as far as its session check either way (issue #211).
describe("bin/quak.ts files", () => {
it("runs without a collection ID", async () => {
expect(await quak("files")).toMatch(/^Not logged in/);
});
it("runs with a collection ID", async () => {
expect(await quak("files", "7")).toMatch(/^Not logged in/);
}); });
}); });
+35 -618
View File
@@ -51,24 +51,13 @@ import {
listMissingThumbnailsCommand, listMissingThumbnailsCommand,
fixMissingThumbnailsCommand, fixMissingThumbnailsCommand,
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.js";
import { stderrLog } from "../../src/cli-log.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 { import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
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,
@@ -122,31 +111,25 @@ const FILES: Record<number, EnteFile[]> = {
// An original is 7 bytes and a thumbnail 3. `failID` makes that file's // An original is 7 bytes and a thumbnail 3. `failID` makes that file's
// original fail; `emptyThumbID` makes the server report that file's // original fail; `emptyThumbID` makes the server report that file's
// thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing // thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing
// the collection's updationTime as the server does. `beachInWork` adds // the collection's updationTime as the server does, and `refreshError` makes
// beach.jpg (100) to Work as well, so that one file is in both albums. // listing collections fail with that message.
// `refreshError` makes listing collections fail with that message.
const fakeClient = ( const fakeClient = (
opts: { opts: {
failID?: number; failID?: number;
emptyThumbID?: number; emptyThumbID?: number;
withNewFile?: boolean; withNewFile?: boolean;
beachInWork?: boolean;
refreshError?: string; refreshError?: string;
} = {}, } = {},
) => { ) => {
const collections = opts.withNewFile const collections = opts.withNewFile
? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]] ? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]]
: COLLECTIONS; : COLLECTIONS;
let files = FILES; const files = opts.withNewFile
if (opts.withNewFile) { ? {
files = { ...FILES,
...files, 1: [...FILES[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }],
1: [...files[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }],
};
}
if (opts.beachInWork) {
files = { ...files, 2: [...files[2], file(100, 2, "beach.jpg")] };
} }
: FILES;
const source: ContentSource = { const source: ContentSource = {
original: async ({ file: f, destination }) => { original: async ({ file: f, destination }) => {
if (f.id === opts.failID) throw new Error("HTTP 500 from server"); if (f.id === opts.failID) throw new Error("HTTP 500 from server");
@@ -188,12 +171,6 @@ 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 = "";
@@ -206,11 +183,9 @@ let root: string;
let stdout: Output; let stdout: Output;
let stderr: Output; let stderr: Output;
// The commands log at the default level, info, as `quak` without `-v` or `-q`
// does; the levels have their own tests, in `log.test.ts`.
const context = (client: Client | null = fakeClient()): CliContext => ({ const context = (client: Client | null = fakeClient()): CliContext => ({
stdout, stdout,
log: stderrLog(stderr, "info"), stderr,
sessionDir: join(root, "session"), sessionDir: join(root, "session"),
cacheDir: join(root, "cache"), cacheDir: join(root, "cache"),
loadSession: () => client, loadSession: () => client,
@@ -263,7 +238,7 @@ describe("session file", () => {
const dir = join(root, "backup"); const dir = join(root, "backup");
expect(await whoamiCommand(ctx)).toBe(3); expect(await whoamiCommand(ctx)).toBe(3);
expect(await collectionsCommand(ctx, {})).toBe(3); expect(await collectionsCommand(ctx, {})).toBe(3);
expect(await filesCommand(ctx, "1", {})).toBe(3); expect(await filesCommand(ctx, { collection: "1" })).toBe(3);
expect(await getCommand(ctx, "100", {})).toBe(3); expect(await getCommand(ctx, "100", {})).toBe(3);
expect(await getThumbCommand(ctx, "100", {})).toBe(3); expect(await getThumbCommand(ctx, "100", {})).toBe(3);
expect(await backupMetadataCommand(ctx, dir, {})).toBe(3); expect(await backupMetadataCommand(ctx, dir, {})).toBe(3);
@@ -510,14 +485,17 @@ describe("collections", () => {
describe("files", () => { describe("files", () => {
it("prints one tab-separated line per file", async () => { it("prints one tab-separated line per file", async () => {
expect(await filesCommand(context(), "1", {})).toBe(0); expect(await filesCommand(context(), { collection: "1" })).toBe(0);
expect(stdout.text).toBe( expect(stdout.text).toBe(
"100\timage\tbeach.jpg\n" + "101\timage\tsunset.jpg\n", "100\timage\tbeach.jpg\n" + "101\timage\tsunset.jpg\n",
); );
}); });
it("prints a JSON array with --json", async () => { it("prints a JSON array with --json", async () => {
const code = await filesCommand(context(), "2", { json: true }); const code = await filesCommand(context(), {
collection: "2",
json: true,
});
expect(code).toBe(0); expect(code).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([ expect(JSON.parse(stdout.text)).toEqual([
{ {
@@ -531,41 +509,9 @@ describe("files", () => {
}); });
it("exits 1 for an unknown collection", async () => { it("exits 1 for an unknown collection", async () => {
expect(await filesCommand(context(), "9", {})).toBe(1); expect(await filesCommand(context(), { collection: "9" })).toBe(1);
expect(stderr.text).toBe("Collection 9 not found\n"); expect(stderr.text).toBe("Collection 9 not found\n");
}); });
// Without a collection ID it lists every file in the account once (issue
// #211). beach.jpg is in Vacation and Work, and Vacation comes first, so it
// is listed once, as Vacation's.
it("without a collection ID prints every file once", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe(
"100\timage\tbeach.jpg\n" +
"101\timage\tsunset.jpg\n" +
"200\timage\tdiagram.png\n",
);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
});
it("without a collection ID gives each file the first album holding it with --json", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
expect(await filesCommand(ctx, undefined, { json: true })).toBe(0);
expect(
JSON.parse(stdout.text).map(
(row: { id: number; collectionID: number }) => [
row.id,
row.collectionID,
],
),
).toEqual([
[100, 1],
[101, 1],
[200, 2],
]);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
});
}); });
describe("get and get-thumb", () => { describe("get and get-thumb", () => {
@@ -695,12 +641,9 @@ describe("names from an album name or a title over 255 bytes", () => {
// 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", () => {
const livePhotoClient = async ( const livePhotoClient = async (image = IMAGE): Promise<Client> => {
image = IMAGE,
title = "IMG_0300.HEIC",
): Promise<Client> => {
const { file: live, body } = await asLivePhoto( const { file: live, body } = await asLivePhoto(
file(300, 1, title), file(300, 1, "IMG_0300.HEIC"),
livePhotoZip({ "image.heic": image, "video.mov": VIDEO }), livePhotoZip({ "image.heic": image, "video.mov": VIDEO }),
livePhotoHash(image, VIDEO), livePhotoHash(image, VIDEO),
); );
@@ -747,44 +690,6 @@ describe("a live photo", () => {
); );
}); });
// Run `get` without `--out` in a new directory for a live photo titled
// `title`, and return its exit code and the names it wrote.
const getInNewDirectory = async (
title: string,
): Promise<{ code: number; names: string[] }> => {
const ctx = context(await livePhotoClient(IMAGE, title));
const dir = mkdtempSync(join(root, "cwd-"));
const previous = process.cwd();
process.chdir(dir);
try {
const code = await getCommand(ctx, "300", {});
return { code, names: readdirSync(dir).sort() };
} finally {
process.chdir(previous);
}
};
const imageName = `${"t".repeat(250)}.heic`;
const videoName = `${"t".repeat(251)}.mov`;
// The title fits, but ".heic" is one byte longer than ".jpg", so the
// image's name is cut short once its extension is in it.
it("get cuts its image's name short for a 255-byte title ending in .jpg, and exits 0", async () => {
const title = `${"t".repeat(251)}.jpg`;
const result = await getInNewDirectory(title);
expect(Buffer.byteLength(title)).toBe(255);
expect(Buffer.byteLength(imageName)).toBe(255);
expect(Buffer.byteLength(videoName)).toBe(255);
expect(result).toEqual({ code: 0, names: [imageName, videoName] });
});
it("get cuts the names of its image and video short for a 300-byte title with no extension, and exits 0", async () => {
const result = await getInNewDirectory("t".repeat(300));
expect(result).toEqual({ code: 0, names: [imageName, videoName] });
});
it("get --out writes the image there and the video beside it", async () => { it("get --out writes the image there and the video beside it", async () => {
const out = join(root, "photo.jpg"); const out = join(root, "photo.jpg");
const video = join(root, "photo.mov"); const video = join(root, "photo.mov");
@@ -905,40 +810,6 @@ describe("backup", () => {
); );
}); });
// A failed file is an error line both where the backup fails it and in
// the list at the end. The last line is fatal, so `-q`, which prints
// fatal lines only, still says why the backup exits 1.
it("logs a failed file at error, and ends with one fatal line counting the failures", async () => {
const dir = join(root, "backup");
const last = `quak: 1 file(s) and 0 album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines\n`;
const ctx = context(fakeClient({ failID: 101 }));
expect(
await backupCommand(
{ ...ctx, log: stderrLog(stderr, "error") },
dir,
{},
),
).toBe(1);
expect(stderr.text).toBe(
"FAILED original sunset.jpg: HTTP 500 from server\n" +
"\nFailed files:\n" +
" [Vacation] sunset.jpg (id 101): HTTP 500 from server\n" +
last,
);
stderr.text = "";
expect(
await backupCommand(
{ ...ctx, log: stderrLog(stderr, "fatal") },
dir,
{ json: true },
),
).toBe(1);
expect(stderr.text).toBe(last);
});
// `--json` shapes stdout only: the log lines stay on stderr, and the JSON
// takes the place of the summary.
it("prints the result as JSON with --json, still exiting 1 on a failure", async () => { it("prints the result as JSON with --json, still exiting 1 on a failure", async () => {
const ctx = context(fakeClient({ failID: 101 })); const ctx = context(fakeClient({ failID: 101 }));
const code = await backupCommand(ctx, join(root, "backup"), { const code = await backupCommand(ctx, join(root, "backup"), {
@@ -953,31 +824,7 @@ describe("backup", () => {
failed: 1, failed: 1,
}); });
expect(result.errors[0].fileID).toBe(101); expect(result.errors[0].fileID).toBe(101);
expect(stderr.text).toContain( expect(stderr.text).toBe("Starting backup...\n");
"Fetching original sunset.jpg (101)...\n",
);
expect(stderr.text).not.toContain("Backup complete");
});
// On an empty cache the library's first refresh fetches the files of
// every album before the backup can start, which on a large account takes
// a while; each album it has fetched the files of is counted on stderr,
// with `--json` too.
it("counts on stderr each album whose files its first refresh fetched, with --json too", async () => {
const start =
"Starting backup...\n" +
FETCHED_BOTH_ALBUMS +
"Refreshing library...\n";
for (const json of [false, true]) {
stderr.text = "";
const dir = mkdtempSync(join(root, "backup-"));
const ctx = {
...context(),
cacheDir: mkdtempSync(join(root, "cache-")),
};
expect(await backupCommand(ctx, dir, { json })).toBe(0);
expect(stderr.text.slice(0, start.length)).toBe(start);
}
}); });
it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => { it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => {
@@ -1067,7 +914,6 @@ describe("backup", () => {
backupCommand(ctx, dir, {}), backupCommand(ctx, dir, {}),
new PassThrough(), new PassThrough(),
runStderr, runStderr,
stderrLog(runStderr, "info"),
resolve, resolve,
); );
}); });
@@ -1308,9 +1154,7 @@ describe("helper list-missing-thumbnails", () => {
expect(stderr.text).toContain("No missing thumbnails found.\n"); expect(stderr.text).toContain("No missing thumbnails found.\n");
}); });
// `--json` shapes stdout only: the progress stays on stderr, and the JSON it("prints a JSON array with --json and no progress", async () => {
// takes the place of the count and the list.
it("prints a JSON array with --json, and its progress on stderr", async () => {
const ctx = context(fakeClient({ emptyThumbID: 200 })); const ctx = context(fakeClient({ emptyThumbID: 200 }));
expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0); expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([ expect(JSON.parse(stdout.text)).toEqual([
@@ -1321,54 +1165,7 @@ describe("helper list-missing-thumbnails", () => {
reason: "empty thumbnail (0 bytes)", reason: "empty thumbnail (0 bytes)",
}, },
]); ]);
expect(stderr.text).toContain(FETCHED_BOTH_ALBUMS); expect(stderr.text).toBe("");
expect(stderr.text).toContain("[Work] Checking thumbnails...\n");
expect(stderr.text).not.toContain("with missing thumbnails");
});
});
describe("helper fix-missing-thumbnails", () => {
// File 999 is in no album, so fixing it fails. The list of failed files
// is at error, and the last line is fatal, so `-q`, which prints fatal
// lines only, still says why the command exits 1.
it("lists a failed file at error, and ends with one fatal line counting the failures", async () => {
const last = `quak: 1 file(s) failed; see the "Failed files:" list, or the JSON with --json\n`;
const ctx = { ...context(), log: stderrLog(stderr, "error") };
expect(await fixMissingThumbnailsCommand(ctx, { file: ["999"] })).toBe(
1,
);
expect(stderr.text).toBe(
"\nFailed files:\n" +
" 999\tunknown\tfile not found in any collection\n" +
last,
);
stderr.text = "";
const quiet = { ...context(), log: stderrLog(stderr, "fatal") };
expect(
await fixMissingThumbnailsCommand(quiet, {
file: ["999"],
json: true,
}),
).toBe(1);
expect(stderr.text).toBe(last);
});
// beach.jpg (100) is in Vacation (1) and Work (2), both updated at the same
// time. The fixer names a file after the first album holding it in the
// order of `Library.albums.list()`. On a tie, the album with the higher ID
// comes first, so beach.jpg is named after Work.
it("names a photo in two albums after the same album on an empty cache as on a cache that holds the account", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
const opts = { file: ["100"], json: true };
const inWork = [{ fileID: 100, collection: "Work", status: "skipped" }];
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject(inWork);
expect(await filesCommand(ctx, undefined, {})).toBe(0);
stdout.text = "";
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject(inWork);
}); });
}); });
@@ -1398,12 +1195,11 @@ describe("backup-metadata --exif", () => {
}); });
}); });
// Each test first runs `files` so the cache holds the account as it was, then // Each test first runs `collections` so the cache holds the account as it was,
// changes the server under it. (`collections` and `files` with a collection ID // then changes the server under it.
// would not do: they store nothing.)
describe("backup-metadata and the thumbnail helpers refresh first", () => { describe("backup-metadata and the thumbnail helpers refresh first", () => {
beforeEach(async () => { beforeEach(async () => {
expect(await filesCommand(context(), undefined, {})).toBe(0); expect(await collectionsCommand(context(), {})).toBe(0);
stdout.text = ""; stdout.text = "";
stderr.text = ""; stderr.text = "";
}); });
@@ -1462,10 +1258,10 @@ describe("backup-metadata and the thumbnail helpers refresh first", () => {
}); });
}); });
// `files`, `get`, `get-thumb` and `helper fix-missing-thumbnails --file` take // `files --collection`, `get`, `get-thumb` and `helper fix-missing-thumbnails
// an ID only when it is written in decimal digits and is a safe integer. // --file` take an ID only when it is written in decimal digits and is a safe
// `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty string as 0, // integer. `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty
// so the command would act on a file the user never named. // string as 0, so the command would act on a file the user never named.
// Anything else is refused with one line naming it, and exit 1, before any // Anything else is refused with one line naming it, and exit 1, before any
// request. // request.
// //
@@ -1519,18 +1315,21 @@ describe("an ID given on the command line", () => {
it("files looks up a decimal collection ID", async () => { it("files looks up a decimal collection ID", async () => {
const ctx = emptyAccount(); const ctx = emptyAccount();
expect(await filesCommand(ctx, "1", {})).toBe(1); expect(await filesCommand(ctx, { collection: "1" })).toBe(1);
expect(stderr.text).toBe("Collection 1 not found\n"); expect(stderr.text).toBe("Collection 1 not found\n");
expect(requests).not.toHaveLength(0); expect(requests).not.toHaveLength(0);
}); });
it.each(NOT_IDS)("files refuses %j before any request", async (arg) => { it.each(NOT_IDS)(
"files refuses --collection %j before any request",
async (arg) => {
const ctx = emptyAccount(); const ctx = emptyAccount();
expect(await filesCommand(ctx, arg, {})).toBe(1); expect(await filesCommand(ctx, { collection: arg })).toBe(1);
expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`); expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`);
expect(stdout.text).toBe(""); expect(stdout.text).toBe("");
expect(requests).toHaveLength(0); expect(requests).toHaveLength(0);
}); },
);
it("get looks up a decimal file ID", async () => { it("get looks up a decimal file ID", async () => {
expect(await getCommand(emptyAccount(), "100", {})).toBe(1); expect(await getCommand(emptyAccount(), "100", {})).toBe(1);
@@ -1585,385 +1384,3 @@ describe("an ID given on the command line", () => {
}, },
); );
}); });
// An account with many albums (issues #210, #217 and #222). On an empty cache
// `collections`, `files` with a collection ID and `get` used to wait for a
// refresh that fetched and decrypted the files of every album, one album after
// another. So did `helper fix-missing-thumbnails --file` (issue #223). On such
// an account they 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`. The tests record the path and query 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 requests: string[] = [];
let server: typeof globalThis.fetch;
let snapshot: ClientSnapshot;
// What `files` without a collection ID prints on an empty cache: every
// album's photo on stdout, and on stderr each album whose files its
// refresh fetched.
let everyPhoto = "";
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
everyPhoto += `${n * 100}\timage\tphoto-${n}.jpg\n`;
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
// The key of each album, by album ID.
const albumKeys = new Map<number, Uint8Array>();
// A photo in album `collectionID` as the server sends it, and its original
// encrypted as the server stores it. The original's bytes are the title.
// These tests fetch no thumbnail. The server records a thumbnail size, so
// `helper fix-missing-thumbnails` downloads the original before it skips
// the photo as not a JPEG.
const photoRow = (photo: {
id: number;
collectionID: number;
title: string;
updationTime: number;
}): { row: Record<string, unknown>; original: Uint8Array } => {
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(
photoKey,
albumKeys.get(photo.collectionID)!,
);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: photo.title,
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
const original = encryptStreamBlob(
new TextEncoder().encode(photo.title),
photoKey,
);
const row = {
id: photo.id,
collectionID: photo.collectionID,
ownerID: USER_ID,
encryptedKey: toBase64(photoKeyBox.ciphertext),
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
file: { decryptionHeader: toBase64(original.header) },
thumbnail: { decryptionHeader: "" },
info: { thumbSize: 1000 },
updationTime: photo.updationTime,
};
return { row, original: original.ciphertext };
};
// Album n is named `Album n` and holds one photo, n * 100, titled
// `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>[]> = {};
const fileCiphertexts: Record<number, Uint8Array> = {};
for (let id = 1; id <= ALBUMS; id++) {
const albumKey = sodium.crypto_secretbox_keygen();
albumKeys.set(id, albumKey);
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 { row, original } = photoRow({
id: id * 100,
collectionID: id,
title: `photo-${id}.jpg`,
updationTime: 1,
});
filesByCollection[id] = [row];
fileCiphertexts[id * 100] = original;
}
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;
const { pathname, search } = new URL(url);
requests.push(pathname + search);
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(() => {
requests.length = 0;
});
// A context whose saved session sends its requests to `fetch`, by default
// the fake server.
const accountContext = (fetch = server): CliContext => {
const ctx = context();
saveSession(ctx.sessionDir, snapshot);
return {
...ctx,
loadSession: (path) => loadSession(path, { fetch }),
};
};
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(requests).toEqual(["/collections/v2?sinceTime=0"]);
// It stored nothing that would make a refresh skip an album's files:
// `files` after it still lists every album's photo.
stdout.text = "";
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe(everyPhoto);
});
it("files with a collection ID asks for the album list and that album's files alone", async () => {
expect(await filesCommand(accountContext(), "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
expect(stderr.text).toBe("");
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=7&sinceTime=0",
]);
});
// Here the server sends album 7's files in two pages. Photo 700 changed
// between them, so both pages hold a copy of it.
it("files with a collection ID lists only the later copy of a photo sent in two pages", async () => {
const firstPage = [
photoRow({
id: 700,
collectionID: 7,
title: "photo-7.jpg",
updationTime: 1,
}).row,
photoRow({
id: 701,
collectionID: 7,
title: "photo-701.jpg",
updationTime: 1,
}).row,
];
const secondPage = [
photoRow({
id: 700,
collectionID: 7,
title: "photo-7-edited.jpg",
updationTime: 2,
}).row,
];
const paged = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : input);
if (
url.pathname !== "/collections/v2/diff" ||
url.searchParams.get("collectionID") !== "7"
) {
return server(input, init);
}
const first = url.searchParams.get("sinceTime") === "0";
return new Response(
JSON.stringify({
diff: first ? firstPage : secondPage,
hasMore: first,
}),
);
}) as typeof globalThis.fetch;
expect(await filesCommand(accountContext(paged), "7", {})).toBe(0);
// Photo 700 is listed once, with the later title, before photo 701.
expect(stdout.text).toBe(
"700\timage\tphoto-7-edited.jpg\n701\timage\tphoto-701.jpg\n",
);
});
// The cache stays empty, so a later `files` without a collection ID still
// fetches the files of all 40 albums.
it("files with a collection ID stores nothing in the cache", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, "7", {})).toBe(0);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
stdout.text = "";
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe(everyPhoto);
expect(stderr.text).toBe(progress);
});
// Photo 200 is in album 2, so `get` asks for no file of album 3 or later.
// The last request downloads the photo's original.
it("get on an empty cache asks for the albums' files only until one holds the photo", async () => {
const out = join(root, "photo.jpg");
expect(await getCommand(accountContext(), "200", { out })).toBe(0);
expect(readFileSync(out, "utf-8")).toBe("photo-2.jpg");
expect(stderr.text).toBe(`11 bytes -> ${out}\n`);
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=1&sinceTime=0",
"/collections/v2/diff?collectionID=2&sinceTime=0",
"/?fileID=200",
]);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
});
// `files` first fills the cache, so `get` waits for a refresh. No album has
// changed since, so the refresh asks for no album's files.
it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, undefined, {})).toBe(0);
requests.length = 0;
stderr.text = "";
const out = join(root, "photo.jpg");
expect(await getCommand(ctx, "4000", { out })).toBe(0);
expect(readFileSync(out, "utf-8")).toBe("photo-40.jpg");
expect(stderr.text).toBe(`12 bytes -> ${out}\n`);
expect(requests).toEqual([
"/collections/v2?sinceTime=1",
"/?fileID=4000",
]);
requests.length = 0;
stderr.text = "";
expect(await getCommand(ctx, "999", { out })).toBe(1);
expect(stderr.text).toBe("File 999 not found\n");
expect(requests).toEqual(["/collections/v2?sinceTime=1"]);
});
// The helper searches the most recently updated album first. All 40 were
// updated at the same time, and on a tie the higher ID comes first. Photo
// 3900 is in album 39, so the helper asks for no file of album 38 or
// earlier. The last request downloads the photo's original.
it("fix-missing-thumbnails --file on an empty cache asks for the albums' files only until one holds the photo", async () => {
const opts = { file: ["3900"], json: true };
expect(await fixMissingThumbnailsCommand(accountContext(), opts)).toBe(
0,
);
const reason =
"unsupported image format (only baseline JPEG can be regenerated)";
expect(JSON.parse(stdout.text)).toEqual([
{
fileID: 3900,
title: "photo-39.jpg",
collection: "Album 39",
status: "skipped",
reason,
},
]);
expect(stderr.text).toBe(
"[Album 39] Downloading photo-39.jpg for thumbnail generation...\n" +
`[Album 39] Skipping photo-39.jpg: ${reason}\n`,
);
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=40&sinceTime=0",
"/collections/v2/diff?collectionID=39&sinceTime=0",
"/?fileID=3900",
]);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
});
// `files` first fills the cache, so the helper waits for a refresh. No
// album has changed since, so the refresh asks for no album's files.
it("fix-missing-thumbnails --file on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, undefined, {})).toBe(0);
requests.length = 0;
stdout.text = "";
stderr.text = "";
const opts = { file: ["3900"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
const reason =
"unsupported image format (only baseline JPEG can be regenerated)";
expect(JSON.parse(stdout.text)).toEqual([
{
fileID: 3900,
title: "photo-39.jpg",
collection: "Album 39",
status: "skipped",
reason,
},
]);
expect(stderr.text).toBe(
"[Album 39] Downloading photo-39.jpg for thumbnail generation...\n" +
`[Album 39] Skipping photo-39.jpg: ${reason}\n`,
);
expect(requests).toEqual([
"/collections/v2?sinceTime=1",
"/?fileID=3900",
]);
requests.length = 0;
stdout.text = "";
const notFound = { file: ["999"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, notFound)).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject([
{ fileID: 999, reason: "file not found in any collection" },
]);
expect(requests).toEqual(["/collections/v2?sinceTime=1"]);
});
it("files counts on stderr each album whose files its refresh fetched", async () => {
expect(await filesCommand(accountContext(), undefined, {})).toBe(0);
expect(stdout.text).toBe(everyPhoto);
expect(stderr.text).toBe(progress);
});
// `--json` shapes stdout only, so the refresh's progress is still counted
// on stderr.
it("files --json counts on stderr each album whose files its refresh fetched", async () => {
const opts = { json: true };
expect(await filesCommand(accountContext(), undefined, opts)).toBe(0);
expect(JSON.parse(stdout.text)).toHaveLength(ALBUMS);
expect(stderr.text).toBe(progress);
});
});
-359
View File
@@ -1,359 +0,0 @@
/**
* Tests for the CLI's log levels, set with `-v`/`--verbose` and `-q`/`--quiet`
* (`src/cli-log.ts`, issue #212).
*
* Every line quak prints on stderr is a log line at one of six levels, from
* the quietest: fatal, error, warning, info, debug, trace. A run prints the
* lines at its own level and at every quieter one. The level is info unless
* asked otherwise: `-v` raises it to debug, `-vv` to trace, and more repeats
* stay at trace. `-q` lowers it to fatal, so a run prints only the line a
* command prints as it fails with a non-zero exit code. stdout holds the
* command's output at every level.
*
* Most tests run `bin/quak.ts`, as the smoke test does, against an account of
* one album holding one photo: the saved session is in a temporary directory,
* and `fetch` is a fake of the Ente server that serves the album and the photo
* encrypted as Ente's clients encrypt them. Each run has a cache directory of
* its own, so its refresh fetches the album's files, as on a first run.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sodium from "libsodium-wrappers-sumo";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import { ApiClient } from "../../src/api/client.js";
import { saveSession } from "../../src/cli-commands.js";
import { logRefresh, logRequests, type Log } from "../../src/cli-log.js";
import { init, toBase64 } from "../../src/crypto/index.js";
import { encryptSecretbox, encryptStreamBlob } from "../ente-server.js";
// `bin/quak.ts` reads the saved session from the data directory env-paths
// gives it, which here is a temporary directory made before the first run.
const paths = vi.hoisted(() => ({ data: "" }));
vi.mock("env-paths", () => ({ default: () => paths }));
const USER_ID = 42;
const root = mkdtempSync(join(tmpdir(), "quak-log-test-"));
const argv = process.argv;
class ExitCalled extends Error {}
// Run `quak <args>` to its exit, with a cache of its own, and return its exit
// code and what it wrote on stdout and on stderr, which are kept off the
// terminal. `process.exit` is stubbed to throw, so nothing runs after it, as
// nothing does after a real exit.
const quak = async (
...args: string[]
): Promise<{ code: number; stdout: string; stderr: string }> => {
let stdout = "";
let stderr = "";
let code = NaN;
vi.spyOn(process.stdout, "write").mockImplementation((text) => {
stdout += String(text);
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((text) => {
stderr += String(text);
return true;
});
vi.spyOn(process, "exit").mockImplementation((exitCode) => {
code = Number(exitCode ?? 0);
throw new ExitCalled();
});
const cache = mkdtempSync(join(root, "cache-"));
process.argv = ["node", "quak", "--cache-dir", cache, ...args];
vi.resetModules();
await expect(import("../../bin/quak.js")).rejects.toBeInstanceOf(
ExitCalled,
);
return { code, stdout, stderr };
};
// Album 1, "Trips", of user 42, holding photo 100, "beach.jpg". The album's
// key is encrypted with the account's master key, the photo's key with the
// album's, and the photo's metadata with its own key. Only the session and
// these two records are needed: the tests never log in.
beforeAll(async () => {
await init();
await sodium.ready;
const masterKey = sodium.crypto_secretbox_keygen();
const keyPair = sodium.crypto_box_keypair();
paths.data = join(root, "session");
saveSession(paths.data, {
email: "log@example.com",
userID: USER_ID,
token: "token",
masterKey: toBase64(masterKey),
secretKey: toBase64(keyPair.privateKey),
publicKey: toBase64(keyPair.publicKey),
});
const albumKey = sodium.crypto_secretbox_keygen();
const albumKeyBox = encryptSecretbox(albumKey, masterKey);
const name = encryptSecretbox(new TextEncoder().encode("Trips"), albumKey);
const album = {
id: 1,
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: "beach.jpg",
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
const photo = {
id: 100,
collectionID: 1,
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 json = (body: unknown): Response =>
new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
});
vi.stubGlobal("fetch", async (input: string) => {
const { pathname } = new URL(input);
if (pathname === "/collections/v2") {
return json({ collections: [album] });
}
if (pathname === "/collections/v2/diff") {
return json({ diff: [photo], hasMore: false });
}
return new Response("not found", { status: 404 });
});
});
afterEach(() => {
process.argv = argv;
vi.restoreAllMocks();
});
afterAll(() => {
vi.unstubAllGlobals();
rmSync(root, { recursive: true, force: true });
});
// What `quak files` writes on stdout at every level.
const FILES = "100\timage\tbeach.jpg\n";
// What it writes on stderr at each level. Info is the refresh's progress, one
// line for the one album whose files it fetched. Debug adds the start and end
// of the refresh, and trace each request with the status of its answer.
const INFO = "Fetched the files of 1 of 1 collections\n";
const DEBUG = "Refresh started\n" + INFO + "Refresh done\n";
const TRACE =
"Refresh started\n" +
"GET https://api.ente.io/collections/v2?sinceTime=0: HTTP 200\n" +
"GET https://api.ente.io/collections/v2/diff?collectionID=1&sinceTime=0: HTTP 200\n" +
INFO +
"Refresh done\n";
describe("the log level", () => {
it("is info by default", async () => {
expect(await quak("files")).toEqual({
code: 0,
stdout: FILES,
stderr: INFO,
});
});
it("is debug with -v", async () => {
expect(await quak("-v", "files")).toEqual({
code: 0,
stdout: FILES,
stderr: DEBUG,
});
});
it("is trace with -vv", async () => {
expect(await quak("-vv", "files")).toEqual({
code: 0,
stdout: FILES,
stderr: TRACE,
});
});
it("stays at trace with -vvv", async () => {
expect((await quak("-vvv", "files")).stderr).toBe(TRACE);
});
it("counts --verbose --verbose as -vv", async () => {
expect((await quak("--verbose", "--verbose", "files")).stderr).toBe(
TRACE,
);
});
// Both are options of the program, taken before or after the command's
// name, as `--cache-dir` is.
it("is set by an option after the command's name too", async () => {
expect((await quak("files", "-v")).stderr).toBe(DEBUG);
expect((await quak("files", "--json", "-vv")).stderr).toBe(TRACE);
expect((await quak("files", "-q")).stderr).toBe("");
});
});
describe("-q", () => {
it("prints nothing on stderr when the command works", async () => {
expect(await quak("-q", "files")).toEqual({
code: 0,
stdout: FILES,
stderr: "",
});
});
// No album holds file 999, so fixing it fails. The summary from
// `--- Done ---` to the end of the `Failed files:` list is left out. The
// line saying why the command failed is the fatal one, and it is printed.
it("prints only the line a failing command exits with", async () => {
const fix = ["helper", "fix-missing-thumbnails", "--file", "999"];
expect(await quak("-q", ...fix)).toEqual({
code: 1,
stdout: "",
stderr: `quak: 1 file(s) failed; see the "Failed files:" list, or the JSON with --json\n`,
});
});
it("is refused with -v, in one line, before the command runs", async () => {
for (const args of [
["-q", "-v", "files"],
["files", "--verbose", "--quiet"],
]) {
const { code, stdout, stderr } = await quak(...args);
expect(code).toBe(1);
expect(stdout).toBe("");
expect(stderr).toBe(
"error: option '-q, --quiet' cannot be used with option '-v, --verbose'\n",
);
}
});
});
// `--help` lists both options, and each command's help lists them too, since
// each command takes them.
describe("--help", () => {
it("lists -v and -q for the program and for each command", async () => {
for (const args of [["--help"], ["files", "--help"]]) {
const { stdout } = await quak(...args);
expect(stdout).toContain("-v, --verbose");
expect(stdout).toContain("-q, --quiet");
}
});
});
// The library writes no lines itself. Its layers report through callbacks,
// and the CLI logs what they report.
describe("what the library reports", () => {
const collect = (): { log: Log; lines: string[] } => {
const lines: string[] = [];
return { log: (level, line) => lines.push(`${level}: ${line}`), lines };
};
// A request answered 503 and retried: each attempt is a request, logged
// at trace with its status, and the retry is logged at debug with how
// long it waits and why.
it("logs each request at trace and each retry at debug", async () => {
const { log, lines } = collect();
const answers = [
new Response("unavailable", { status: 503 }),
new Response("{}", {
headers: { "content-type": "application/json" },
}),
];
const client = new ApiClient({
...logRequests(log, {
sleep: async () => {},
random: () => 0.5,
}),
fetch: async () => answers.shift()!,
});
await client.getJSON("/collections/v2", { sinceTime: 0 });
const url = "https://api.ente.io/collections/v2?sinceTime=0";
expect(lines).toEqual([
`trace: GET ${url}: HTTP 503`,
"debug: Attempt 1 failed, retrying in 250 ms: HTTP 503",
`trace: GET ${url}: HTTP 200`,
]);
});
it("logs a request that failed with its error", async () => {
const { log, lines } = collect();
const client = new ApiClient({
...logRequests(log, { attempts: 1 }),
fetch: async () => {
throw new TypeError("fetch failed", {
cause: new Error("connect ECONNREFUSED 1.2.3.4:443"),
});
},
});
await expect(client.getJSON("/collections/v2")).rejects.toThrow();
expect(lines).toEqual([
"trace: GET https://api.ente.io/collections/v2: fetch failed: connect ECONNREFUSED 1.2.3.4:443",
]);
});
// Each operation is named in plain words.
it("logs a refresh's progress at info, and its start and end at debug", () => {
const { log, lines } = collect();
const onProgress = logRefresh(log);
onProgress({ operation: "refresh", status: "started" });
onProgress({
operation: "refresh",
status: "progress",
fetched: 1,
total: 2,
});
onProgress({
operation: "refresh",
status: "failed",
error: "HTTP 503",
});
onProgress({ operation: "fetchMLData", status: "started" });
onProgress({ operation: "fetchMLData", status: "done", fetched: 3 });
expect(lines).toEqual([
"debug: Refresh started",
"info: Fetched the files of 1 of 2 collections",
"debug: Refresh failed: HTTP 503",
"debug: ML data fetch started",
"debug: ML data fetch done",
]);
});
});
+17 -152
View File
@@ -41,14 +41,12 @@ import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { init, toBase64, encryptBlob } from "../../src/crypto/index.js"; import { init, toBase64, encryptBlob } from "../../src/crypto/index.js";
import * as jpegJs from "jpeg-js"; import * as jpegJs from "jpeg-js";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
import { DISCOVER_CATEGORIES } from "../../src/clip-keyword-vectors.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import { import {
runMetadataBackup, runMetadataBackup,
type MetadataBackupOptions, type MetadataBackupOptions,
} from "../../src/metadata-backup.js"; } from "../../src/metadata-backup.js";
import { backupMetadataCommand } from "../../src/cli-commands.js"; import { backupMetadataCommand } from "../../src/cli-commands.js";
import { stderrLog, type LogLevel } from "../../src/cli-log.js";
import { import {
buildAccount, buildAccount,
buildMetaFetch, buildMetaFetch,
@@ -57,8 +55,8 @@ import {
type MetaMockState, type MetaMockState,
} from "../ente-server.js"; } from "../ente-server.js";
// One file per ML data request, so each file of the mock account is fetched in // One file per ML data request, so the two files of the mock account are
// a request of its own and one of them can fail on its own. // fetched in two requests and one of them can fail on its own.
vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({ vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../src/mldata-fetch.js")>()), ...(await importOriginal<typeof import("../../src/mldata-fetch.js")>()),
MLDATA_BATCH_SIZE: 1, MLDATA_BATCH_SIZE: 1,
@@ -67,21 +65,6 @@ vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({
const TEST_EMAIL = "metabackup@example.com"; const TEST_EMAIL = "metabackup@example.com";
const TEST_PASSWORD = "metapass"; const TEST_PASSWORD = "metapass";
// The CLIP embedding of a photo of a sunset (see clip-keywords.test.ts), which
// the ML data of beach.jpg holds.
const SUNSET_EMBEDDING: number[] = JSON.parse(
readFileSync(
new URL("../clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
).embedding;
// Identity, a Discover category of Ente's photos app that leaves out videos.
const IDENTITY = DISCOVER_CATEGORIES.find((c) => c.title === "Identity")!;
// ML data holding faces and no CLIP embedding, which file 302 has.
const FACES_ONLY = { face: { faces: [{ faceID: "face-def", score: 0.9 }] } };
let mock: MetaMockState; let mock: MetaMockState;
let testDir: string; let testDir: string;
@@ -220,65 +203,6 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
updationTime: 1710000000000000, updationTime: 1710000000000000,
}; };
// A file in coll 10 with the ML data given. `fileType` is Ente's number: 0
// an image, 1 a video.
const vacationFile = (
id: number,
title: string,
fileType: number,
mlData: object,
) => {
const fileKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { ciphertext: encKey, nonce } = encryptSecretbox(fileKey, ck1);
const meta = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title,
fileType,
creationTime: 1700000000000000,
modificationTime: 1700000000000000,
}),
),
fileKey,
);
const ml = encryptBlob(
new Uint8Array(gzipSync(Buffer.from(JSON.stringify(mlData)))),
fileKey,
);
return {
raw: {
id,
collectionID: 10,
ownerID: 42,
encryptedKey: toBase64(encKey),
keyDecryptionNonce: toBase64(nonce),
metadata: {
encryptedData: toBase64(meta.ciphertext),
decryptionHeader: toBase64(meta.header),
},
file: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
thumbnail: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
updationTime: 1700000000000000,
},
mlData: {
encryptedData: toBase64(ml.ciphertext),
decryptionHeader: toBase64(ml.header),
},
};
};
// Files 300 and 301 in coll 10: a photo and a video whose ML data holds
// only a CLIP embedding, Identity's own text embedding.
const identityML = { clip: { embedding: IDENTITY.textEmbedding } };
const identityPhoto = vacationFile(300, "passport.jpg", 0, identityML);
const identityVideo = vacationFile(301, "passport.mp4", 1, identityML);
// File 302 in coll 10: a photo whose ML data holds faces and no CLIP
// embedding.
const facesPhoto = vacationFile(302, "party.jpg", 0, FACES_ONLY);
// Encrypt ML data for file 100 (gzipped JSON, encrypted with file key) // Encrypt ML data for file 100 (gzipped JSON, encrypted with file key)
const mlPayload = JSON.stringify({ const mlPayload = JSON.stringify({
face: { face: {
@@ -305,7 +229,7 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
clip: { clip: {
version: 1, version: 1,
client: "test", client: "test",
embedding: SUNSET_EMBEDDING, embedding: [0.5, 0.6, 0.7],
}, },
}); });
const gzipped = gzipSync(Buffer.from(mlPayload)); const gzipped = gzipSync(Buffer.from(mlPayload));
@@ -345,23 +269,12 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
keyAttributes: account.keyAttributes, keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken, encryptedToken: account.encryptedToken,
collections: [rawColl1, rawColl2], collections: [rawColl1, rawColl2],
filesByCollection: { filesByCollection: { 10: [rawFile1], 20: [rawFile2] },
10: [
rawFile1,
identityPhoto.raw,
identityVideo.raw,
facesPhoto.raw,
],
20: [rawFile2],
},
encryptedMLData: { encryptedMLData: {
100: { 100: {
encryptedData: toBase64(mlCiphertext), encryptedData: toBase64(mlCiphertext),
decryptionHeader: toBase64(mlHeader), decryptionHeader: toBase64(mlHeader),
}, },
300: identityPhoto.mlData,
301: identityVideo.mlData,
302: facesPhoto.mlData,
}, },
fileCiphertexts: { 100: encFileBody1 }, fileCiphertexts: { 100: encFileBody1 },
}; };
@@ -518,9 +431,7 @@ describe("quak backup-metadata", () => {
expect(account.email).toBe(TEST_EMAIL); expect(account.email).toBe(TEST_EMAIL);
}); });
// The CLIP embedding is left out of the ML data, and the keywords it gives it("fetches and decrypts ML data by default", async () => {
// are written in its place.
it("fetches and decrypts ML data by default, with keywords for the CLIP embedding", async () => {
const outDir = join(testDir, "ml-data"); const outDir = join(testDir, "ml-data");
await runBackup(outDir); await runBackup(outDir);
@@ -540,41 +451,8 @@ describe("quak backup-metadata", () => {
expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc"); expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc");
expect(fileMeta.mlData.face.faces[0].score).toBeCloseTo(0.98); expect(fileMeta.mlData.face.faces[0].score).toBeCloseTo(0.98);
expect(fileMeta.mlData.face.faces[0].detection.box.x).toBeCloseTo(0.1); expect(fileMeta.mlData.face.faces[0].detection.box.x).toBeCloseTo(0.1);
expect(fileMeta.mlData).not.toHaveProperty("clip"); expect(fileMeta.mlData.clip).toBeDefined();
expect(fileMeta.keywords).toEqual(["Sunset"]); expect(fileMeta.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
});
// Ente's photos app keeps videos out of some Discover categories, Identity
// among them. Files 300 and 301, a photo and a video, share one CLIP
// embedding, Identity's own text embedding, so the photo gets the keyword
// Identity and the video does not.
it("keeps a video out of a category that leaves out videos", async () => {
expect(IDENTITY.showVideo).toBe(false);
const outDir = join(testDir, "video-keywords");
await runBackup(outDir);
const vacation = join(outDir, "collections", "10-Vacation");
const fileJSON = (fileID: number) =>
JSON.parse(readFileSync(join(vacation, `${fileID}.json`), "utf-8"));
expect(fileJSON(300).keywords).toContain("Identity");
expect(fileJSON(301)).toHaveProperty("keywords");
expect(fileJSON(301).keywords).not.toContain("Identity");
});
// A file's ML data can hold faces and no CLIP embedding. File 302's JSON
// then has the ML data and no keywords.
it("writes no keywords for ML data without a CLIP embedding", async () => {
const outDir = join(testDir, "no-clip");
await runBackup(outDir);
const fileMeta = JSON.parse(
readFileSync(
join(outDir, "collections", "10-Vacation", "302.json"),
"utf-8",
),
);
expect(fileMeta.mlData).toEqual(FACES_ONLY);
expect(fileMeta).not.toHaveProperty("keywords");
}); });
it("extracts EXIF from downloaded files when --exif is set", async () => { it("extracts EXIF from downloaded files when --exif is set", async () => {
@@ -612,12 +490,9 @@ describe("quak backup-metadata", () => {
}); });
describe("quak backup-metadata when a file's ML data cannot be read", () => { describe("quak backup-metadata when a file's ML data cannot be read", () => {
// Run the CLI command against `fetch`, the mock unless given, logging at // Run the CLI command against `fetch`, the mock unless given, and return
// `level`, and return its exit code, stderr and output directory. // its exit code, stderr and output directory.
const runCommand = async ( const runCommand = async (fetch = buildMetaFetch(mock)) => {
fetch = buildMetaFetch(mock),
level: LogLevel = "info",
) => {
const client = await Client.login({ const client = await Client.login({
email: TEST_EMAIL, email: TEST_EMAIL,
password: TEST_PASSWORD, password: TEST_PASSWORD,
@@ -631,10 +506,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
const code = await backupMetadataCommand( const code = await backupMetadataCommand(
{ {
stdout: { write: () => true }, stdout: { write: () => true },
log: stderrLog( stderr: { write: (text: string) => (stderr += text) },
{ write: (text: string) => (stderr += text) },
level,
),
sessionDir: testDir, sessionDir: testDir,
cacheDir: mkdtempSync(join(testDir, "cache-")), cacheDir: mkdtempSync(join(testDir, "cache-")),
loadSession: () => client, loadSession: () => client,
@@ -645,16 +517,13 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
return { code, stderr, outDir }; return { code, stderr, outDir };
}; };
// The failed request is logged at error, so it is the first line printed
// at that level.
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( const { code, stderr, outDir } = await runCommand(
buildMetaFetch(mock, 200), buildMetaFetch(mock, 200),
"error",
); );
expect(code).toBe(1); expect(code).toBe(1);
expect(stderr).toMatch(/^ML data request for 1 file\(s\) failed: /); expect(stderr).toContain("ML data request for 1 file(s) failed");
const ok = JSON.parse( const ok = JSON.parse(
readFileSync( readFileSync(
@@ -662,7 +531,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
"utf-8", "utf-8",
), ),
); );
expect(ok.keywords).toEqual(["Sunset"]); expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
expect(ok.mlDataError).toBeUndefined(); expect(ok.mlDataError).toBeUndefined();
const failed = JSON.parse( const failed = JSON.parse(
@@ -677,9 +546,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
}); });
// The server sends ML data for diagram.png encrypted under a key that is // The server sends ML data for diagram.png encrypted under a key that is
// not the file's, so quak cannot decrypt it. That is logged at error, and // not the file's, so quak cannot decrypt it.
// the last line is fatal, so `-q`, which prints fatal lines only, still
// says why the command exits 1.
it("writes the reason for ML data that does not decrypt, and exits 1", async () => { it("writes the reason for ML data that does not decrypt, and exits 1", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptBlob( const { header, ciphertext } = encryptBlob(
@@ -697,14 +564,12 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
}, },
}, },
}), }),
"error",
); );
expect(code).toBe(1); expect(code).toBe(1);
const reason = "secretstream chunk authentication failed"; const reason = "secretstream chunk authentication failed";
expect(stderr).toBe( expect(stderr).toContain(
`ML data of file 200 could not be read: ${reason}\n` + `ML data of file 200 could not be read: ${reason}\n`,
`quak: the ML data of 1 file(s) could not be read; see mlDataError in their JSON in ${outDir}\n`,
); );
const failed = JSON.parse( const failed = JSON.parse(
readFileSync( readFileSync(
@@ -720,7 +585,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
"utf-8", "utf-8",
), ),
); );
expect(ok.keywords).toEqual(["Sunset"]); expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
}); });
it("exits 0 when every ML data request succeeds", async () => { it("exits 0 when every ML data request succeeds", async () => {
+47 -178
View File
@@ -3,25 +3,17 @@
* issue #36, issue #52). * issue #36, issue #52).
* *
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for * The `collections`, `files`, `get`, and `get-thumb` commands must answer for
* current server state, not the local cache. `get`, `get-thumb` and `files` * current server state, not the local cache, so each helper forces a
* without a collection ID force a `Library.fresh()` round-trip before they * `Library.fresh()` round-trip before it reads. The stand-in library below
* read. The stand-in library below serves no cached record until `fresh()` has * serves nothing until `fresh()` has been awaited, so a helper that read
* been awaited, so a helper that read without refreshing would come back empty * without refreshing would come back empty and fail here.
* and fail here. `collections` asks the library for the collection list alone
* (`freshCollections()`, issue #210). `files` with a collection ID asks for
* that list, then for that one collection's files (`freshFiles()`, issue
* #217). Neither may wait for a refresh, which would fetch the files of every
* collection. On an empty cache, `get` and `get-thumb` ask for that list, then
* for one collection's files at a time, until they find the file (issue #222).
* `helper fix-missing-thumbnails --file` does the same until it finds every
* file it names, reading the most recently updated collection first (issue
* #223).
* *
* `collections` and `files` also list in the order the library gives them, not * `collections` and `files` also list in the library's enumeration order
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
* the albums/photos projection's newest-first order. The fixtures are seeded in * the albums/photos projection's newest-first order. The fixtures are seeded in
* an order that a newest-first sort would rearrange, so a regression to the * an enumeration order that a newest-first sort would rearrange, so a
* projection order would fail here too. Field values still come from the raw * regression to the projection order would fail here too. Field values still
* metadata via `cli-output.ts`. * come from the raw metadata via `cli-output.ts`.
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
@@ -30,10 +22,10 @@ import {
freshCollections, freshCollections,
freshFiles, freshFiles,
freshFile, freshFile,
freshFilesByID,
type FreshReadLibrary, type FreshReadLibrary,
} from "../../src/cli-read.js"; } from "../../src/cli-read.js";
import { fileListRow } from "../../src/cli-output.js"; import { fileListRow } from "../../src/cli-output.js";
import type { Photo } from "../../src/library/index.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
const collection = (id: number, updationTime: number): Collection => ({ const collection = (id: number, updationTime: number): Collection => ({
@@ -67,15 +59,11 @@ const file = (
updationTime: creationTime, updationTime: creationTime,
}); });
// A library that reveals its cached records only after `fresh()` has been // A library that reveals its records only after `fresh()` has been awaited, and
// awaited, and serves them in the enumeration order it was given. // serves them in the enumeration order it was given. `photos.byID` returns a
// `freshCollections()` and `freshFiles()` answer in that order without a // stand-in `Photo` carrying just the fileID the helper passes through.
// refresh.
class FakeLibrary implements FreshReadLibrary { class FakeLibrary implements FreshReadLibrary {
freshCalls = 0; freshCalls = 0;
freshCollectionsCalls = 0;
// The ID of each collection `freshFiles()` was asked for.
freshFilesCalls: number[] = [];
private refreshed = false; private refreshed = false;
constructor( constructor(
@@ -89,20 +77,14 @@ class FakeLibrary implements FreshReadLibrary {
return {}; return {};
} }
async freshCollections(): Promise<Collection[]> {
this.freshCollectionsCalls++;
return this.collections;
}
async freshFiles(collection: Collection): Promise<EnteFile[]> {
this.freshFilesCalls.push(collection.id);
return this.files.filter((f) => f.collectionID === collection.id);
}
listCollections(): Collection[] { listCollections(): Collection[] {
return this.refreshed ? this.collections : []; return this.refreshed ? this.collections : [];
} }
getCollection(id: number): Collection | undefined {
return this.listCollections().find((c) => c.id === id);
}
listFiles(collectionID: number): EnteFile[] { listFiles(collectionID: number): EnteFile[] {
return this.refreshed return this.refreshed
? this.files.filter((f) => f.collectionID === collectionID) ? this.files.filter((f) => f.collectionID === collectionID)
@@ -113,10 +95,18 @@ class FakeLibrary implements FreshReadLibrary {
if (!this.refreshed) return undefined; if (!this.refreshed) return undefined;
return this.files.find((f) => f.id === fileID); return this.files.find((f) => f.id === fileID);
} }
photos = {
byID: ({ fileID }: { fileID: number }): Photo | undefined => {
if (!this.refreshed) return undefined;
if (!this.files.some((f) => f.id === fileID)) return undefined;
return { fileID } as unknown as Photo;
},
};
} }
describe("CLI read helpers (issue #36 amendment, issue #52)", () => { describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
it("freshCollections reads the collection list alone, in enumeration order", async () => { it("freshCollections refreshes first, then lists 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)],
@@ -124,8 +114,7 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
); );
const rows = await freshCollections(lib); const rows = await freshCollections(lib);
expect(lib.freshCollectionsCalls).toBe(1); expect(lib.freshCalls).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.
@@ -136,24 +125,18 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
expect(rows.map((c) => c.id)).not.toEqual(newestFirst); expect(rows.map((c) => c.id)).not.toEqual(newestFirst);
}); });
it("freshFiles with a collection ID reads that collection's files alone and keeps their order and raw fields", async () => { it("freshFiles refreshes first, lists in enumeration order, keeps raw fields", async () => {
// Given in the order 10, 11, 12; creationTimes ascending, so a // Enumeration order by id 10, 11, 12; creationTimes ascending, so a
// newest-first sort would reverse them. // newest-first sort would reverse them.
const files = [ const files = [
file(10, 1, 1_700_000_000_000_000), file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001), file(11, 1, 1_700_000_000_000_001),
file(12, 1, 1_700_000_000_000_002), file(12, 1, 1_700_000_000_000_002),
file(20, 2, 1_700_000_000_000_003),
]; ];
const lib = new FakeLibrary( const lib = new FakeLibrary([collection(1, 100)], files);
[collection(1, 100), collection(2, 200)],
files,
);
const rows = await freshFiles(lib, 1); const rows = await freshFiles(lib, 1);
expect(lib.freshCalls).toBe(0); expect(lib.freshCalls).toBe(1);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([1]);
expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]); expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]);
// Field values come from raw metadata: microsecond creationTime and the // Field values come from raw metadata: microsecond creationTime and the
@@ -183,144 +166,30 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
]); ]);
}); });
// Without a collection ID, `files` lists every file in the account once
// (issue #211). File 11 is in collections 1 and 2; collection 2 is
// enumerated first, so 11 is listed once, with collection 2's record.
it("freshFiles without a collection ID lists every file once, from the first collection holding it", async () => {
const files = [
file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001),
file(20, 2, 1_700_000_000_000_002),
file(11, 2, 1_700_000_000_000_001),
];
const lib = new FakeLibrary(
[collection(2, 100), collection(1, 200)],
files,
);
const rows = await freshFiles(lib);
expect(lib.freshCalls).toBe(1);
expect(rows?.map((f) => [f.id, f.collectionID])).toEqual([
[20, 2],
[11, 2],
[10, 1],
]);
});
it("freshFiles returns undefined for an unknown collection", async () => { it("freshFiles returns undefined for an unknown collection", async () => {
const lib = new FakeLibrary([collection(1, 100)], []); const lib = new FakeLibrary([collection(1, 100)], []);
const rows = await freshFiles(lib, 999); const rows = await freshFiles(lib, 999);
expect(lib.freshCalls).toBe(0); expect(lib.freshCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([]);
expect(rows).toBeUndefined(); expect(rows).toBeUndefined();
}); });
// Three collections, each holding one file. it("freshFile refreshes first, then resolves the photo and its raw record", async () => {
const threeCollections = (): FakeLibrary => const f = file(10, 1, 1_700_000_000_000_000);
new FakeLibrary( const lib = new FakeLibrary([collection(1, 100)], [f]);
[collection(1, 100), collection(2, 200), collection(3, 300)],
[ const resolved = await freshFile(lib, 10);
file(10, 1, 1_700_000_000_000_000), expect(lib.freshCalls).toBe(1);
file(20, 2, 1_700_000_000_000_001), expect(resolved?.photo.fileID).toBe(10);
file(30, 3, 1_700_000_000_000_002), expect(resolved?.file.metadata.title).toBe("file-10.jpg");
], expect(resolved?.file.metadata.creationTime).toBe(
1_700_000_000_000_000,
); );
it("freshFile on an empty cache reads each collection's files in the server's order until one holds the file", async () => {
const lib = threeCollections();
const found = await freshFile(lib, 20);
expect(lib.freshCalls).toBe(0);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([1, 2]);
expect(found?.metadata.title).toBe("file-20.jpg");
expect(found?.metadata.creationTime).toBe(1_700_000_000_000_001);
}); });
// The stand-in's cache holds what the server holds once `fresh()` has run. it("freshFile returns undefined for an unknown file", async () => {
it("freshFile on a cache that holds the collections refreshes, then looks the file up in the cache", async () => { const lib = new FakeLibrary([collection(1, 100)], []);
const lib = threeCollections(); const resolved = await freshFile(lib, 404);
await lib.fresh(); expect(lib.freshCalls).toBe(1);
expect(resolved).toBeUndefined();
const found = await freshFile(lib, 30);
expect(lib.freshCalls).toBe(2);
expect(lib.freshCollectionsCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(found?.collectionID).toBe(3);
});
it("freshFile on an empty cache returns undefined for an unknown file, after reading every collection's files", async () => {
const lib = threeCollections();
const found = await freshFile(lib, 404);
expect(lib.freshCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([1, 2, 3]);
expect(found).toBeUndefined();
});
// The server lists collections 1, 2 and 3, and collection 3 was updated
// most recently. File 11 is in collections 1 and 2.
const fileInTwoCollections = (): FakeLibrary =>
new FakeLibrary(
[collection(1, 100), collection(2, 200), collection(3, 300)],
[
file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001),
file(11, 2, 1_700_000_000_000_001),
file(20, 2, 1_700_000_000_000_002),
file(30, 3, 1_700_000_000_000_003),
],
);
it("freshFilesByID on an empty cache reads the most recently updated collection's files first, until it has found every file", async () => {
const lib = fileInTwoCollections();
const found = await freshFilesByID(lib, [11, 20]);
expect(lib.freshCalls).toBe(0);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([3, 2]);
expect(found.get(11)?.collection.id).toBe(2);
expect(found.get(11)?.file.collectionID).toBe(2);
expect(found.get(20)?.collection.id).toBe(2);
});
// Collection 1 was updated last, though collections 2 and 3 have higher
// IDs. File 11 is in collections 1 and 2.
it("freshFilesByID on an empty cache reads the most recently updated collection first, though its ID is the lowest", async () => {
const lib = new FakeLibrary(
[collection(1, 400), collection(2, 200), collection(3, 300)],
[
file(11, 1, 1_700_000_000_000_001),
file(11, 2, 1_700_000_000_000_001),
],
);
const found = await freshFilesByID(lib, [11]);
expect(lib.freshFilesCalls).toEqual([1]);
expect(found.get(11)?.collection.id).toBe(1);
expect(found.get(11)?.file.collectionID).toBe(1);
});
// The stand-in's cache holds what the server holds once `fresh()` has run.
it("freshFilesByID on a cache that holds the collections refreshes, then finds each file in the most recently updated collection that holds it", async () => {
const lib = fileInTwoCollections();
await lib.fresh();
const found = await freshFilesByID(lib, [11, 404]);
expect(lib.freshCalls).toBe(2);
expect(lib.freshCollectionsCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(found.get(11)?.collection.id).toBe(2);
expect(found.get(11)?.file.collectionID).toBe(2);
expect(found.has(404)).toBe(false);
});
it("freshFilesByID on an empty cache reads every collection's files when a file is in none of them", async () => {
const lib = fileInTwoCollections();
const found = await freshFilesByID(lib, [10, 404]);
expect(lib.freshFilesCalls).toEqual([3, 2, 1]);
expect(found.get(10)?.collection.id).toBe(1);
expect(found.has(404)).toBe(false);
}); });
}); });
+1 -8
View File
@@ -6,7 +6,6 @@ import { PassThrough } from "node:stream";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { ApiError } from "../../src/api/client.js"; import { ApiError } from "../../src/api/client.js";
import { stderrLog } from "../../src/cli-log.js";
import { run } from "../../src/cli-run.js"; import { run } from "../../src/cli-run.js";
// A stream whose written text is kept in `text`; writes finish at once, so // A stream whose written text is kept in `text`; writes finish at once, so
@@ -24,13 +23,7 @@ const runToExit = async (
const stdout = collector(); const stdout = collector();
const stderr = collector(); const stderr = collector();
const code = await new Promise<number>((resolve) => { const code = await new Promise<number>((resolve) => {
void run( void run(command, stdout.stream, stderr.stream, resolve);
command,
stdout.stream,
stderr.stream,
stderrLog(stderr.stream, "info"),
resolve,
);
}); });
return { code, stdout: stdout.text(), stderr: stderr.text() }; return { code, stdout: stdout.text(), stderr: stderr.text() };
}; };
-109
View File
@@ -1,109 +0,0 @@
{
"photo": "https://upload.wikimedia.org/wikipedia/commons/d/db/Sunset_off_the_Massachusetts_coast_-_NOAA.jpg",
"sha256": "be62f3289c9bf7b75af68cd6978021d54e8a531d5d4cc493f6e9fbf5e1ab74d3",
"embedding": [
-0.0021123015, -0.04437615, -0.062354308, 0.006249992, 0.022141185,
0.011159019, 0.01227392, -0.00030532264, 0.03186437, 0.09196922,
0.039111234, -0.037084445, -0.032962628, -0.019820206, -0.013141825,
0.0047183544, 0.017137334, 0.046581306, 0.0047622235, 0.04982521,
0.023128154, 0.032080866, 0.037709303, 0.019241406, 0.0032100384,
-0.014275843, 0.049337693, 0.025953151, 0.014263995, -0.0062328726,
-0.040504098, -0.015083303, -0.008123875, -0.006233639, -0.05352683,
-0.0048083956, 0.0763486, -0.013155067, 0.025114346, 0.022016674,
0.0001588023, -0.010341572, -0.0037518279, -0.019181008, 0.04549337,
-0.0010725886, 0.004636866, 0.017177915, -0.0117317885, 0.0026097358,
0.030738998, 0.013158572, -0.08856403, -0.0121391015, 0.011477229,
-0.0033104012, 0.035524756, -0.018607879, -0.0071739843, -0.031906743,
0.0056713386, -0.0044195866, 0.00292471, 0.00061686034, -0.043075223,
0.09040427, 0.001522537, -0.011445652, 0.009609886, -0.0053201183,
-0.0057452377, 0.00972853, -0.0024397348, -0.021255083, 0.015893811,
-0.006020548, -0.09428023, 0.014277583, -0.017975299, -0.011878315,
0.011825521, 0.00014203975, -0.0020437292, -0.05305343, -0.024840152,
-0.011336872, -0.002796541, -0.00036171873, 0.004530499, 0.044486605,
0.021602718, -0.01292857, 0.024530916, 0.019559948, -0.039928272,
-0.0681174, 0.049101, 0.02461532, 0.0365531, -0.04070358, -0.011011939,
-0.0027188526, -0.020370876, 0.012904856, 0.043157287, -0.051658224,
-0.3726012, 0.019549074, -0.0499252, -0.04800559, -0.011493859,
0.02893256, 0.060341954, 0.0013929688, -0.051283866, -0.024286803,
0.044124287, -0.005995806, -0.062817656, 0.007369614, -0.021470634,
-0.028147545, 0.00411659, 0.04234773, 0.03787647, 0.008579682,
-0.038568925, -0.027498025, -0.028416378, 0.0022012168, 0.010906191,
-0.013745787, 0.018267838, 0.013531097, -0.061559845, 0.004682517,
-0.005993303, 0.0028113858, -0.0028286583, 0.0026029332, -0.06693403,
-0.0072381413, -0.02419584, 0.0037009937, -0.0011296495, -0.030310456,
0.0005999609, 0.009945354, 0.024915686, 0.038672045, 0.023009432,
-0.05093051, -0.09101409, -0.06686167, -0.06211481, -0.022600474,
0.0024902367, -0.0067171026, -0.016769778, -0.0032803628, 0.008623083,
-0.0016110836, 0.014925073, 0.033811048, 0.012829449, -0.052575592,
0.03728281, -0.0051201633, 0.021238064, -0.031086605, -0.0043402985,
0.25054494, -0.028396713, -0.010725523, -0.023445267, 0.021751683,
0.003130971, 0.012270548, -0.014836475, 0.014466885, 0.0021478906,
-0.088153616, 0.015356315, 0.039639276, -0.040283255, 0.014923924,
-0.029303031, -0.004475201, 0.047299225, -0.06726505, -0.012491676,
-0.002080582, 0.03506341, 0.04146718, 0.0074114986, -0.009582633,
0.006623749, -0.05454898, -0.0034387247, -0.014780821, 0.03698328,
-0.034441486, 0.22032379, -0.021792699, 0.008001907, 0.0341019,
0.0025784625, -0.0071689896, -0.014151225, -0.050547987, -0.030372301,
-0.03288388, -0.042987447, -0.0033656165, 0.021502906, -0.0030374376,
-0.024413, -0.03506603, 0.030891743, 0.0069994032, 0.004887366,
0.030707076, 0.0065348223, 0.0008240555, 0.016809713, -0.024151647,
-0.0040458064, 0.044567548, 0.03908212, -0.061782435, 0.0026002831,
0.02003242, -0.0115451, -0.045135964, -0.0663542, 0.005240975,
0.0010777109, -0.009276002, -0.011919081, 0.027654119, -0.013634551,
0.0129154315, -0.013195512, 0.040571272, -0.099290505, -0.04656693,
-0.20979854, 0.04181004, -0.06769166, -0.04121946, 0.018260444,
0.005617506, -0.15694222, 0.032472793, 0.014189598, 0.008345103,
-0.041118417, 0.0074527003, -0.011494104, 0.004906474, -0.0027100458,
0.0009994594, -0.041061398, -0.045274835, 0.015187793, 0.034275036,
-0.02813289, 0.02098019, -0.015043198, 0.03716667, 0.013542642,
-0.008104835, 0.014965486, -0.027978558, 0.021163663, -0.006942737,
-0.034969814, -0.038961593, 0.016802894, -0.012318175, -0.047821593,
-0.01933216, 0.0064432374, 0.0322358, -0.020437784, 0.02659447,
-0.03022022, -0.019443465, -0.094178624, -0.017009107, 0.03860671,
-0.00023695534, -0.033089854, -0.014677462, 0.012356399, 0.018040499,
-0.19360113, 0.011710817, -0.019871691, 0.020124774, 0.024948105,
-0.024573864, -0.028349072, -0.041731454, 0.032646254, 0.015098165,
0.0018447012, -0.01129152, -0.02481687, 0.036048, 0.20797811,
0.04317757, -0.017239755, 0.033431605, -0.011384422, 0.0099174455,
-0.19739223, -0.008901062, -0.056426458, 0.014591864, 0.0044474266,
0.008209326, -0.012241819, 0.042553645, -0.021552453, 0.008611734,
0.048944127, -0.013872049, 0.019179989, 0.008183264, -0.053223096,
-0.027327595, -0.0033542586, -0.012353858, -0.010941209, 0.010212546,
0.029714134, -0.011385593, -0.0013529479, 0.014505385, 0.01148663,
0.012613636, -0.0074430504, -0.014008101, -0.028146038, -0.042778917,
0.02143165, 0.016812252, -0.06286215, 0.02085532, -0.035128277,
0.0241282, 0.01397908, -0.034992814, 0.013153766, 0.0068236147,
0.013594184, -0.06859393, 0.051849406, 0.029675119, -0.0037699796,
-0.00013216575, 0.02916647, 0.031859048, -0.015006051, 0.0001439966,
0.0026584554, -0.0046948595, 0.039754897, 0.034873154, 0.0012870439,
-0.011516082, -0.074089095, 0.019241294, 0.00076195505, 0.0027334464,
-0.036206458, -0.0011109262, -0.048838835, -0.00204685, 0.01179991,
0.0068136845, 0.009852495, 0.017308988, 0.03159545, 0.058803353,
0.038116634, 0.008836143, -0.010460637, -0.010994099, -0.0012926942,
-0.048370842, 0.037696566, -0.08093239, -0.0041242186, -0.005866373,
0.02048607, -0.12549639, 0.011705739, -0.0021425558, 0.018078592,
-0.038228087, 0.004245971, -0.11560507, 0.01381203, -0.02734893,
0.029595908, 0.101082794, -0.042842254, -0.08397301, -0.1546237,
-0.037624564, 0.0069606556, -0.008020479, 0.0043268185, 0.016504597,
0.044554908, -0.05240209, 0.020519335, -0.050994243, -0.019861093,
0.022066997, -0.0188911, 0.012919696, -0.022872692, -0.012961374,
-0.020071112, 0.0074553066, 0.06402014, 0.04065211, 0.014139608,
0.06221546, -0.02574805, 0.010114545, 0.020619567, 0.032264773,
-0.009148828, 0.022849955, -0.016593235, -0.032456633, -0.006737303,
-0.0037493357, -0.065912865, -0.0024181998, -0.0037048983, -0.019304488,
0.009085037, 0.057529982, -0.0378338, 0.01792042, 0.01885012,
-0.0043600523, 0.017382368, -0.027792772, 0.006749118, -0.0053297766,
-0.011628587, 0.015736576, 0.024801968, 0.011543913, -0.01159873,
-0.000759976, 0.042183932, 0.05764386, -0.06422416, 0.010577374,
-0.02261249, 0.02282793, -0.05141075, -0.11287078, -0.020670144,
-0.020953743, 0.030885734, -0.012597732, -0.121472664, 0.028447151,
-0.0415664, -0.010061708, 0.06441338, 0.0271207, -0.0016693748,
-0.0045694243, 0.020342957, 0.030528478, 0.007129517, -0.0029600926,
-0.007061397, 0.06036486, -0.004736018, -0.0090786135, -0.07601116,
-0.039556272, -0.051269546, -0.019473685, 0.029858151, 0.028464556,
0.012424476, 0.013742254, 0.008742927, 0.006065751, -0.009892663,
0.07699999, -0.003443197, 0.0027863425, 0.029197995, 0.0211013,
0.01098379, 0.016684106, -0.028917195, 0.008122037, 0.0265241,
-0.04461223
]
}
-129
View File
@@ -1,129 +0,0 @@
// `clipKeywords`: the keywords quak writes for a file in place of its CLIP
// embedding.
//
// Ente's ML data for a file holds a CLIP embedding, 512 numbers its image
// model gave the photo. Ente's photos app puts a file in a category of its
// Discover section when the cosine similarity of that embedding and the text
// embedding of the category's query is at least the category's minimum score,
// and keeps videos out of some categories. `clipKeywords` applies that rule
// and returns the titles of the categories a file falls in, highest score
// first; `quak backup` and `quak backup-metadata` write them as `keywords`.
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { DISCOVER_CATEGORIES } from "../src/clip-keyword-vectors.js";
import { clipKeywords } from "../src/clip-keywords.js";
// 512 numbers: the ones given, then zeros.
const vector = (...start: number[]): number[] => [
...start,
...new Array<number>(512 - start.length).fill(0),
];
const withEmbedding = (embedding: unknown) => ({
face: { faces: [] },
clip: { embedding },
});
// Two made-up categories, each with a text embedding along one axis. The
// embedding [3, 4] has a cosine similarity of 3/5 with "Across" and 4/5 with
// "Up". Videos are kept out of "Across".
const ACROSS = {
title: "Across",
query: "across",
minScore: 0.6,
showVideo: false,
textEmbedding: vector(1),
};
const UP = {
title: "Up",
query: "up",
minScore: 0.5,
showVideo: true,
textEmbedding: vector(0, 1),
};
describe("clipKeywords", () => {
it("gives each category whose minimum score the similarity reaches, highest score first", () => {
expect(
clipKeywords(withEmbedding(vector(3, 4)), false, [ACROSS, UP]),
).toEqual(["Up", "Across"]);
});
it("leaves out a category when the similarity is just below its minimum score", () => {
const stricter = { ...ACROSS, minScore: 0.6 + 1e-9 };
expect(
clipKeywords(withEmbedding(vector(3, 4)), false, [stricter, UP]),
).toEqual(["Up"]);
});
it("keeps a video out of a category that leaves out videos", () => {
expect(
clipKeywords(withEmbedding(vector(3, 4)), true, [ACROSS, UP]),
).toEqual(["Up"]);
});
it("gives an empty list for an embedding that falls in no category", () => {
expect(
clipKeywords(withEmbedding(vector(0, 0, 1)), false, [ACROSS, UP]),
).toEqual([]);
});
it.each([
["no CLIP embedding", { face: { faces: [] } }],
["a CLIP part that is not an object", { clip: null }],
["a CLIP part without an embedding", { clip: {} }],
["an embedding of 511 numbers", withEmbedding(vector(3, 4).slice(1))],
["an embedding with NaN in it", withEmbedding(vector(3, NaN))],
["an embedding with Infinity in it", withEmbedding(vector(Infinity))],
["an embedding of strings", withEmbedding(vector(3, 4).map(String))],
["an embedding of zeros", withEmbedding(vector())],
])("gives no keywords for ML data with %s", (_, mlData) => {
expect(clipKeywords(mlData, false, [ACROSS, UP])).toBeUndefined();
});
});
// The CLIP embedding Ente's image model gives a public-domain NOAA photo of a
// sunset, made by bin/clip-keyword-vectors from the photo at `photo`.
const SUNSET: { photo: string; sha256: string; embedding: number[] } =
JSON.parse(
readFileSync(
new URL("clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
);
describe("Ente's Discover categories", () => {
it("are the 16 of Ente's photos app, each with a text embedding of norm 1", () => {
expect(DISCOVER_CATEGORIES.map((c) => c.title)).toEqual([
"Identity",
"Screenshots",
"QR Codes",
"Receipts",
"Notes",
"Memes",
"Visiting Cards",
"Babies",
"Pets",
"Selfies",
"Wallpapers",
"Food",
"Celebrations",
"Sunset",
"Hills",
"Greenery",
]);
for (const category of DISCOVER_CATEGORIES) {
expect(category.textEmbedding).toHaveLength(512);
expect(Math.hypot(...category.textEmbedding)).toBeCloseTo(1, 5);
}
});
it("give a photo of a sunset the keyword Sunset and no other", () => {
expect(
clipKeywords({ clip: { embedding: SUNSET.embedding } }, false),
).toEqual(["Sunset"]);
});
});
-47
View File
@@ -1923,53 +1923,6 @@ describe("downloadFile live photos", () => {
]); ]);
}); });
// A file system refuses a file name over 255 bytes. Each name is cut
// short once its own extension is in it.
const imageName = `${"t".repeat(250)}.heic`;
const videoName = `${"t".repeat(251)}.mov`;
// The title fits, but ".heic" is one byte longer than ".jpg".
it("cuts the image's name short for a 255-byte title ending in .jpg when no outPath is given", async () => {
const title = `${"t".repeat(251)}.jpg`;
const t = setup(livePhotoZip(), { ...livePhoto, title });
const result = await inDirectory(t.dir, () =>
downloadFile(t.api, t.file),
);
expect(Buffer.byteLength(title)).toBe(255);
expect(Buffer.byteLength(imageName)).toBe(255);
expect(Buffer.byteLength(videoName)).toBe(255);
expect(result.path).toBe(imageName);
expect(result.videoPath).toBe(videoName);
expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]);
});
it("cuts the names of the image and the video short for a 300-byte title with no extension when no outPath is given", async () => {
const t = setup(livePhotoZip(), {
...livePhoto,
title: "t".repeat(300),
});
const result = await inDirectory(t.dir, () =>
downloadFile(t.api, t.file),
);
expect(result.path).toBe(imageName);
expect(result.videoPath).toBe(videoName);
expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]);
});
// The caller's path is used as is; only the extension is each part's own.
it("keeps an outPath too long for a file name as given", async () => {
const t = setup(livePhotoZip(), livePhoto);
const outPath = join(t.dir, `${"t".repeat(251)}.jpg`);
await expect(downloadFile(t.api, t.file, outPath)).rejects.toThrow(
/ENAMETOOLONG/,
);
});
it("gives each part its own extension from the ZIP, letters and digits only", async () => { it("gives each part its own extension from the ZIP, letters and digits only", async () => {
const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO }); const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO });
const t = setup(zip, livePhoto); const t = setup(zip, livePhoto);
+3 -65
View File
@@ -15,7 +15,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
defaultFileName,
safeExtension, safeExtension,
sanitizeFileName, sanitizeFileName,
shortenKeepingExtension, shortenKeepingExtension,
@@ -142,17 +141,9 @@ describe("shortenKeepingExtension", () => {
); );
}); });
// A name cut down to its extension would start with a dot, and so be it("cuts the extension short when it alone does not fit", () => {
// hidden.
it("keeps the first character before the extension, cutting the extension short to leave room for it", () => {
expect(shortenKeepingExtension(`b.${"e".repeat(300)}`, 255)).toBe( expect(shortenKeepingExtension(`b.${"e".repeat(300)}`, 255)).toBe(
`b.${"e".repeat(253)}`, `.${"e".repeat(254)}`,
);
expect(shortenKeepingExtension(`ab.${"e".repeat(300)}`, 255)).toBe(
`a.${"e".repeat(253)}`,
);
expect(shortenKeepingExtension(`é.${"e".repeat(300)}`, 255)).toBe(
`é.${"e".repeat(252)}`,
); );
// "€" takes three bytes, so the extension is cut to 253 and the // "€" takes three bytes, so the extension is cut to 253 and the
// part before it keeps the two bytes left over, for the "a". // part before it keeps the two bytes left over, for the "a".
@@ -170,59 +161,6 @@ describe("shortenKeepingExtension", () => {
).toBe(`${"t".repeat(247)} (7).jpg`); ).toBe(`${"t".repeat(247)} (7).jpg`);
expect( expect(
shortenKeepingExtension(`b.${"e".repeat(300)}`, 255, " (7)"), shortenKeepingExtension(`b.${"e".repeat(300)}`, 255, " (7)"),
).toBe(`b (7).${"e".repeat(249)}`); ).toBe(` (7).${"e".repeat(250)}`);
});
});
describe("defaultFileName", () => {
it("is the title when it is safe and fits", () => {
expect(defaultFileName("IMG_0001.HEIC", 42)).toBe("IMG_0001.HEIC");
});
it("replaces unsafe characters, and is file-<id> for an empty title", () => {
expect(defaultFileName("../x.jpg", 42)).toBe("__x.jpg");
expect(defaultFileName("", 42)).toBe("file-42");
});
it("cuts a title over 255 bytes short, keeping its extension", () => {
expect(defaultFileName(`${"t".repeat(296)}.jpg`, 42)).toBe(
`${"t".repeat(251)}.jpg`,
);
expect(defaultFileName(`b.${"e".repeat(300)}`, 42)).toBe(
`b.${"e".repeat(253)}`,
);
});
it("puts `prefix` before the title and leaves room for it", () => {
expect(defaultFileName("beach.jpg", 42, { prefix: "thumb_" })).toBe(
"thumb_beach.jpg",
);
expect(
defaultFileName(`${"t".repeat(296)}.jpg`, 42, { prefix: "thumb_" }),
).toBe(`thumb_${"t".repeat(245)}.jpg`);
});
// A live photo's image and video are each named with their own
// extension, and the name is cut short once that extension is in it.
it("puts `ext` in place of the title's extension before cutting the name short", () => {
expect(defaultFileName("IMG_0001.HEIC", 42, { ext: ".mov" })).toBe(
"IMG_0001.mov",
);
expect(defaultFileName("", 42, { ext: ".mov" })).toBe("file-42.mov");
expect(
defaultFileName(`${"t".repeat(251)}.jpg`, 42, { ext: ".heic" }),
).toBe(`${"t".repeat(250)}.heic`);
expect(defaultFileName("t".repeat(300), 42, { ext: ".heic" })).toBe(
`${"t".repeat(250)}.heic`,
);
});
// Cutting `CONSOLE` or `NULL` short before a long extension leaves `CON`
// or `NUL`, so the name gets the `_` `sanitizeFileName` gives a device
// name, and the part before the extension gives up a byte for it.
it("never returns a Windows device name, even once cut short", () => {
const ext = `.${"e".repeat(251)}`;
expect(defaultFileName(`CONSOLE${ext}`, 42)).toBe(`_CO${ext}`);
expect(defaultFileName(`NULL${ext}`, 42)).toBe(`_NU${ext}`);
}); });
}); });
-6
View File
@@ -253,12 +253,6 @@ describe("Library content wiring", () => {
await expect( await expect(
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }), lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
).rejects.toThrow(/content cache/i); ).rejects.toThrow(/content cache/i);
await expect(lib.originalOf(file(1, 1))).rejects.toThrow(
/content cache/i,
);
await expect(lib.thumbnailOf(file(1, 1))).rejects.toThrow(
/content cache/i,
);
await lib.close(); await lib.close();
}); });
}); });
-19
View File
@@ -375,25 +375,6 @@ describe("ContentCache.original / thumbnail", () => {
await cache.open(); await cache.open();
await expect(cache.original(999)).rejects.toThrow(/unknown file/i); await expect(cache.original(999)).rejects.toThrow(/unknown file/i);
}); });
// `getFile` knows no file here, so the cache can fetch only the file it is
// given.
it("originalOf and thumbnailOf fetch and cache the file they are given", async () => {
const { cache, source } = buildCache({ files: [] });
await cache.open();
const original = await cache.originalOf(file(5));
const thumbnail = await cache.thumbnailOf(file(5));
expect(source.originalCalls).toEqual([5]);
expect(source.thumbnailCalls).toEqual([5]);
expect(original.path).toBe(join(cacheDir, "originals", "5.jpg"));
expect(thumbnail.path).toBe(join(cacheDir, "thumbnails", "5.jpg"));
expect(cache.pathsFor(5)).toEqual({
originalPath: original.path,
thumbnailPath: thumbnail.path,
});
});
}); });
describe("ContentCache.ensureThumbnails", () => { describe("ContentCache.ensureThumbnails", () => {
-70
View File
@@ -14,9 +14,6 @@
* 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
@@ -273,70 +270,3 @@ 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();
}
});
});
-104
View File
@@ -26,9 +26,6 @@
* 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.
@@ -584,107 +581,6 @@ 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")
-31
View File
@@ -555,36 +555,6 @@ describe("withRetry", () => {
expect(delays).toHaveLength(2); expect(delays).toHaveLength(2);
}); });
it("tells `onRetry` of each attempt it retries, before the wait", async () => {
// A caller that logs retries, as the CLI does with `-v`, learns why
// each attempt failed and how long the next one is put off. The last
// attempt is not retried, so it is not reported.
const { sleep, delays } = recordingSleep();
const retries: [string, number, number][] = [];
let calls = 0;
const failure = withRetry(
() => {
calls++;
return Promise.reject(new ApiError(`attempt ${calls}`, 503));
},
{
attempts: 3,
sleep,
random: () => 0.5,
onRetry: (err, attempt, delayMs) => {
retries.push([(err as Error).message, attempt, delayMs]);
},
},
);
await expect(failure).rejects.toThrow("attempt 3");
expect(retries).toEqual([
["attempt 1", 1, 250],
["attempt 2", 2, 500],
]);
expect(delays).toEqual([250, 500]);
});
it("does not retry at all when attempts is 1", async () => { it("does not retry at all when attempts is 1", async () => {
const { sleep, delays } = recordingSleep(); const { sleep, delays } = recordingSleep();
let calls = 0; let calls = 0;
@@ -738,7 +708,6 @@ describe("retry defaults", () => {
expect(resolved.maxDelayMs).toBe(DEFAULT_RETRY_OPTIONS.maxDelayMs); expect(resolved.maxDelayMs).toBe(DEFAULT_RETRY_OPTIONS.maxDelayMs);
expect(typeof resolved.sleep).toBe("function"); expect(typeof resolved.sleep).toBe("function");
expect(typeof resolved.random).toBe("function"); expect(typeof resolved.random).toBe("function");
expect(typeof resolved.onRetry).toBe("function");
}); });
it("resolves to the defaults when given nothing", () => { it("resolves to the defaults when given nothing", () => {
+12 -65
View File
@@ -6,12 +6,10 @@
* working thumbnails, others return 404 or empty bodies. The tests * working thumbnails, others return 404 or empty bodies. The tests
* verify that the detection and repair logic handles each case correctly. * verify that the detection and repair logic handles each case correctly.
* *
* `listMissingThumbnails` takes an open `Library` to enumerate the files. Both * As of issue #52 both helpers take an open `Library` for enumeration and the
* helpers take the `Client` for the API operations: the thumbnail existence * `Client` for the API operations that stay unchanged (the thumbnail existence
* check, and the encrypt-and-upload path. `fixMissingThumbnails` takes the * check, and the encrypt-and-upload path). `fixMissingThumbnails` reads each
* record of each file, which these tests look up with `cachedFilesByID`. It * original through the library's content cache (`photo.original()`).
* reads each original through the library's content cache, by
* `lib.originalOf(file)`.
* *
* `fixMissingThumbnails` is the most complex function in quak: it * `fixMissingThumbnails` is the most complex function in quak: it
* downloads the original file, generates a JPEG thumbnail with jpeg-js, * downloads the original file, generates a JPEG thumbnail with jpeg-js,
@@ -43,7 +41,6 @@ import {
import { SRP, SrpServer } from "fast-srp-hap"; import { SRP, SrpServer } from "fast-srp-hap";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import { cachedFilesByID } from "../../src/cli-read.js";
import { import {
listMissingThumbnails, listMissingThumbnails,
fixMissingThumbnails, fixMissingThumbnails,
@@ -547,22 +544,11 @@ describe("listMissingThumbnails", () => {
const client = await login(counted.fetch, { ...noWait }); const client = await login(counted.fetch, { ...noWait });
const lib = await openLib(client); const lib = await openLib(client);
const errors: string[] = []; const missing = await listMissingThumbnails(lib, client);
const missing = await listMissingThumbnails(
lib,
client,
(msg, level) => {
if (level === "error") errors.push(msg);
},
);
lib.close(); lib.close();
// Only the genuinely empty thumbnail is reported. The file that // Only the genuinely empty thumbnail is reported.
// could not be checked is reported as an error.
expect(missing.map((m) => m.fileID)).toEqual([101]); expect(missing.map((m) => m.fileID)).toEqual([101]);
expect(errors).toEqual([
expect.stringMatching(/^\[.*\] Could not check file-102\.jpg: /),
]);
// And the 500 was retried rather than accepted as an answer: four // And the 500 was retried rather than accepted as an answer: four
// attempts is the library default. // attempts is the library default.
expect(counted.matched()).toBe(4); expect(counted.matched()).toBe(4);
@@ -659,12 +645,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -701,12 +682,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [999]);
lib,
client,
[999],
cachedFilesByID(lib, [999]),
);
lib.close(); lib.close();
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -723,16 +699,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const errors: string[] = []; const results = await fixMissingThumbnails(lib, client, [101, 102]);
const results = await fixMissingThumbnails(
lib,
client,
[101, 102],
cachedFilesByID(lib, [101, 102]),
(msg, level) => {
if (level === "error") errors.push(msg);
},
);
lib.close(); lib.close();
expect(results.length).toBe(2); expect(results.length).toBe(2);
@@ -740,10 +707,6 @@ describe("fixMissingThumbnails", () => {
const failure = results.find((r) => r.fileID === 102)!; const failure = results.find((r) => r.fileID === 102)!;
expect(success.status).toBe("fixed"); expect(success.status).toBe("fixed");
expect(failure.status).toBe("failed"); expect(failure.status).toBe("failed");
// The failed file, and only it, is reported as an error.
expect(errors).toEqual([
expect.stringMatching(/^\[.*\] FAILED file-102\.jpg: /),
]);
}); });
it("skips a non-JPEG image and a video as unsupported, not failed (issue #17)", async () => { it("skips a non-JPEG image and a video as unsupported, not failed (issue #17)", async () => {
@@ -759,7 +722,6 @@ describe("fixMissingThumbnails", () => {
lib, lib,
client, client,
[101, 103, 104], [101, 103, 104],
cachedFilesByID(lib, [101, 103, 104]),
); );
lib.close(); lib.close();
@@ -794,12 +756,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(counted.fetch); const client = await login(counted.fetch);
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results[0]!.status).toBe("skipped"); expect(results[0]!.status).toBe("skipped");
@@ -820,12 +777,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(counted.fetch); const client = await login(counted.fetch);
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results[0]!.status).toBe("skipped"); expect(results[0]!.status).toBe("skipped");
@@ -866,12 +818,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results[0]!.status).toBe("fixed"); expect(results[0]!.status).toBe("fixed");