Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
334de784ff |
@@ -605,8 +605,8 @@ the smallest does not.
|
|||||||
per unique file, two for a live photo: see
|
per unique file, two for a live photo: see
|
||||||
below)
|
below)
|
||||||
YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak
|
YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak
|
||||||
keeps, its private and public magic
|
keeps, and its private and public magic
|
||||||
metadata, and its ML data
|
metadata
|
||||||
YYYY-MM-DD.<fileID>.livephoto.json
|
YYYY-MM-DD.<fileID>.livephoto.json
|
||||||
which of a live photo's two files is which
|
which of a live photo's two files is which
|
||||||
collections/
|
collections/
|
||||||
@@ -625,14 +625,6 @@ name as uploaded, case kept, or `.bin` when it has none or it holds anything but
|
|||||||
letters and digits. When the date or the time zone changes, the next run saves
|
letters and digits. When the date or the time zone changes, the next run saves
|
||||||
the original at its new path and leaves the old copy where it is.
|
the original at its new path and leaves the old copy where it is.
|
||||||
|
|
||||||
A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as
|
|
||||||
`mlData`, the same payload `backup-metadata` writes; a file Ente has no ML data
|
|
||||||
for has no `mlData`. The backup waits for the library's ML data fetch to finish
|
|
||||||
before it writes the JSON files. If that fetch fails, each file whose ML data is
|
|
||||||
not in the cache gets the reason in `mlDataError` instead and counts as failed,
|
|
||||||
and the next run fetches it again. The JSON files are rewritten on every run, so
|
|
||||||
ML data that arrived since the last run appears.
|
|
||||||
|
|
||||||
`failures.json` records each failed file with the kind of failure, how many
|
`failures.json` records each failed file with the kind of failure, how many
|
||||||
times it has been tried and when it was last tried. A file leaves it once it
|
times it has been tried and when it was last tried. A file leaves it once it
|
||||||
succeeds, or once it is no longer in the library or in the backup's scope. The
|
succeeds, or once it is no longer in the library or in the backup's scope. The
|
||||||
@@ -669,7 +661,8 @@ subsequent runs, existing originals are skipped. If a download fails, the error
|
|||||||
is logged and the backup continues with the next file. The exit code is non-zero
|
is logged and the backup continues with the next file. The exit code is non-zero
|
||||||
if any files failed. `quak backup` opens its library with the thumbnail and
|
if any files failed. `quak backup` opens its library with the thumbnail and
|
||||||
originals precache off, so the only file content it fetches is the originals the
|
originals precache off, so the only file content it fetches is the originals the
|
||||||
backup stores.
|
backup stores. The library's ML data fetch still runs and fills the cache's
|
||||||
|
`mldata/`.
|
||||||
|
|
||||||
Each original is written to a temporary file in the same directory, synced to
|
Each original is written to a temporary file in the same directory, synced to
|
||||||
disk, and renamed into place, so an original is either complete or absent, even
|
disk, and renamed into place, so an original is either complete or absent, even
|
||||||
@@ -889,12 +882,11 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
|
|||||||
- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as
|
- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as
|
||||||
`fresh()` does, puts every in-scope original not already at its save path
|
`fresh()` does, puts every in-scope original not already at its save path
|
||||||
there as `photo.download()` does (and, with `includeThumbnails`, fetches
|
there as `photo.download()` does (and, with `includeThumbnails`, fetches
|
||||||
thumbnails) through the content cache, waits for an ML data fetch, and
|
thumbnails) through the content cache, and rebuilds the on-disk backup tree
|
||||||
rebuilds the on-disk backup tree, each file's JSON with its ML data, with a
|
with a durable failure ledger. A fetched original is written straight to its
|
||||||
durable failure ledger. A fetched original is written straight to its save
|
save path and not into the cache, which then counts it as present; one the
|
||||||
path and not into the cache, which then counts it as present; one the cache
|
cache already held is copied from there. `BackupOptions`: `downloadDirectory`
|
||||||
already held is copied from there. `BackupOptions`: `downloadDirectory` (falls
|
(falls back to the library's), `includeOriginals` (default `true`),
|
||||||
back to the library's), `includeOriginals` (default `true`),
|
|
||||||
`includeThumbnails` (default `false`), `onlyAlbumNames`, and `onProgress`. See
|
`includeThumbnails` (default `false`), `onlyAlbumNames`, and `onProgress`. See
|
||||||
Backup layout above for the tree it writes.
|
Backup layout above for the tree it writes.
|
||||||
|
|
||||||
|
|||||||
@@ -33,13 +33,6 @@ declares one.
|
|||||||
and downloads all use them. What is retried and the backoff formula are
|
and downloads all use them. What is retried and the backoff formula are
|
||||||
unchanged.
|
unchanged.
|
||||||
|
|
||||||
- 2026-10-05: `quak backup` writes each file's ML data (its faces and CLIP
|
|
||||||
embedding) into the file's JSON as `mlData`, the payload
|
|
||||||
`lib.mldata.forFile()` returns (issue 163). `lib.backup()` waits for an ML
|
|
||||||
data fetch before it writes the JSON files. When that fetch fails, each file
|
|
||||||
whose ML data is not cached gets the reason in `mlDataError` and counts as
|
|
||||||
failed, and the next run fetches it again.
|
|
||||||
|
|
||||||
- 2026-10-03: The download-albums example test no longer fails on a disk with
|
- 2026-10-03: The download-albums example test no longer fails on a disk with
|
||||||
under 50 GiB free (issue 160). It opens its libraries with
|
under 50 GiB free (issue 160). It opens its libraries with
|
||||||
`freeBelowBytes: 0`, so the free space of the disk it runs on cannot shrink
|
`freeBelowBytes: 0`, so the free space of the disk it runs on cannot shrink
|
||||||
|
|||||||
+16
-60
@@ -3,15 +3,14 @@
|
|||||||
// `lib.backup()` waits for a completed refresh of the library (a failed one
|
// `lib.backup()` waits for a completed refresh of the library (a failed one
|
||||||
// fails the backup before any file is touched), then, for every file in scope,
|
// fails the backup before any file is touched), then, for every file in scope,
|
||||||
// puts its original at its save path under `downloadDirectory`, as
|
// puts its original at its save path under `downloadDirectory`, as
|
||||||
// `Photo.download()` does, waits for an ML data fetch, and rebuilds the derived
|
// `Photo.download()` does, and rebuilds the derived views (per-file sidecars,
|
||||||
// views (per-file sidecars, per-collection symlink trees, per-collection JSON)
|
// per-collection symlink trees, per-collection JSON) from the model. The
|
||||||
// from the model. The on-disk layout:
|
// on-disk layout:
|
||||||
//
|
//
|
||||||
// <downloadDirectory>/
|
// <downloadDirectory>/
|
||||||
// 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
|
||||||
// the file's ML data
|
|
||||||
// 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
|
||||||
// failures.json durable ledger of unresolved failures
|
// failures.json durable ledger of unresolved failures
|
||||||
@@ -30,15 +29,13 @@
|
|||||||
// directories of albums that no longer exist.
|
// directories of albums that no longer exist.
|
||||||
//
|
//
|
||||||
// Resilience (issue #8): no per-file condition aborts the run. A failed
|
// Resilience (issue #8): no per-file condition aborts the run. A failed
|
||||||
// download, a failed symlink, or ML data missing because the ML data fetch
|
// download or a failed symlink is caught, recorded in `failures.json` with a
|
||||||
// failed is caught, recorded in `failures.json` with a classification, a
|
// classification, a running attempt count, and the last-tried time, and the run
|
||||||
// running attempt count, and the last-tried time, and the run continues.
|
// continues. `result.failed` — and thus the CLI's exit code — stays non-zero
|
||||||
// `result.failed` — and thus the CLI's exit code — stays non-zero while any
|
// while any failure remains unresolved and clears once every one succeeds. Each
|
||||||
// failure remains unresolved and clears once every one succeeds. Each run
|
// run reconciles the ledger against the files it attempted, so an entry for a
|
||||||
// reconciles the ledger against the files it attempted, so an entry for a file
|
// file that has since left the library (deleted) or this run's scope is dropped
|
||||||
// that has since left the library (deleted) or this run's scope is dropped
|
// rather than counted forever, which would poison a scheduled backup's exit code.
|
||||||
// rather than counted forever, which would poison a scheduled backup's exit
|
|
||||||
// code.
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
lstatSync,
|
lstatSync,
|
||||||
@@ -63,7 +60,6 @@ import {
|
|||||||
storedAtSavePath,
|
storedAtSavePath,
|
||||||
} from "./library/content.js";
|
} from "./library/content.js";
|
||||||
import { representative } from "./library/records.js";
|
import { representative } from "./library/records.js";
|
||||||
import type { MLData } from "./mldata-fetch.js";
|
|
||||||
import type { Collection, EnteFile } from "./model/types.js";
|
import type { Collection, EnteFile } from "./model/types.js";
|
||||||
|
|
||||||
export type ProgressCallback = (message: string) => void;
|
export type ProgressCallback = (message: string) => void;
|
||||||
@@ -121,13 +117,6 @@ export interface BackupLibrary {
|
|||||||
destination: string,
|
destination: string,
|
||||||
): Promise<{ path: string; videoPath?: string }>;
|
): Promise<{ path: string; videoPath?: string }>;
|
||||||
thumbnail(fileID: number): Promise<{ path: string }>;
|
thumbnail(fileID: number): Promise<{ path: string }>;
|
||||||
// Wait for an ML data fetch to complete, joining one already running or
|
|
||||||
// starting one. Rejects with the reason when it fails; resolves at once
|
|
||||||
// when the library cannot fetch ML data.
|
|
||||||
fetchMLData(): Promise<void>;
|
|
||||||
// A file's cached ML data, as `lib.mldata.forFile()` returns it, or
|
|
||||||
// undefined when none is cached.
|
|
||||||
mlData(fileID: number): Promise<MLData | undefined>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FailureClass = "transient" | "permanent" | "unknown";
|
type FailureClass = "transient" | "permanent" | "unknown";
|
||||||
@@ -351,13 +340,7 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The file's JSON: its basic fields, its magic metadata, and its ML data, or
|
const writeSidecar = (path: string, file: EnteFile): void => {
|
||||||
// the reason the ML data is missing.
|
|
||||||
const writeSidecar = (
|
|
||||||
path: string,
|
|
||||||
file: EnteFile,
|
|
||||||
ml: { mlData?: MLData; mlDataError?: string },
|
|
||||||
): void => {
|
|
||||||
const meta: Record<string, unknown> = {
|
const meta: Record<string, unknown> = {
|
||||||
id: file.id,
|
id: file.id,
|
||||||
collectionID: file.collectionID,
|
collectionID: file.collectionID,
|
||||||
@@ -366,8 +349,6 @@ 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) meta.mlData = ml.mlData;
|
|
||||||
if (ml.mlDataError) meta.mlDataError = ml.mlDataError;
|
|
||||||
writeFileSync(path, JSON.stringify(meta, null, 2));
|
writeFileSync(path, JSON.stringify(meta, null, 2));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -516,37 +497,12 @@ export const runBackup = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: rebuild the derived views from the model. Sidecars first, for
|
// Phase 2: rebuild the derived views from the model. Sidecars first, for
|
||||||
// every present original (this repairs stale ones), each with the file's
|
// every present original (this repairs stale ones).
|
||||||
// ML data once an ML data fetch has completed. When the fetch fails, a
|
|
||||||
// file with no cached ML data gets the reason instead and is recorded as
|
|
||||||
// failed. The next run fetches its ML data again because none is cached.
|
|
||||||
if (includeOriginals) {
|
if (includeOriginals) {
|
||||||
let mlDataError: string | undefined;
|
|
||||||
try {
|
|
||||||
log("Fetching ML data...");
|
|
||||||
await lib.fetchMLData();
|
|
||||||
} catch (err) {
|
|
||||||
mlDataError = errorMessage(err);
|
|
||||||
log(`FAILED ML data: ${mlDataError}`);
|
|
||||||
}
|
|
||||||
for (const file of distinct.values()) {
|
for (const file of distinct.values()) {
|
||||||
if (storedAtSavePath(downloadDirectory, file) === undefined) {
|
if (storedAtSavePath(downloadDirectory, file) !== undefined) {
|
||||||
continue;
|
const path = savePath(downloadDirectory, file);
|
||||||
}
|
writeSidecar(withExtension(path, ".json"), file);
|
||||||
const path = withExtension(
|
|
||||||
savePath(downloadDirectory, file),
|
|
||||||
".json",
|
|
||||||
);
|
|
||||||
const mlData = await lib.mlData(file.id);
|
|
||||||
if (mlData === undefined && mlDataError !== undefined) {
|
|
||||||
writeSidecar(path, file, { mlDataError });
|
|
||||||
recordFailure(
|
|
||||||
file,
|
|
||||||
collectionName.get(file.collectionID) ?? "",
|
|
||||||
new Error(`ML data: ${mlDataError}`),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
writeSidecar(path, file, { mlData });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-23
@@ -279,8 +279,7 @@ export class Library {
|
|||||||
private cycle?: Promise<void>;
|
private cycle?: Promise<void>;
|
||||||
// Guards the ML fetch pass so a slow backfill never runs twice at once; a
|
// Guards the ML fetch pass so a slow backfill never runs twice at once; a
|
||||||
// refresh whose pass is still running kicks nothing new. Holds the running
|
// refresh whose pass is still running kicks nothing new. Holds the running
|
||||||
// pass, so `close()` and `backup()` can wait for it. It rejects when the
|
// pass, so `close()` can wait for it.
|
||||||
// pass fails.
|
|
||||||
private mlFetch?: Promise<void>;
|
private mlFetch?: Promise<void>;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private lastRefreshAt?: number;
|
private lastRefreshAt?: number;
|
||||||
@@ -570,9 +569,8 @@ export class Library {
|
|||||||
// does, joining one already running, and rejects before touching any file
|
// does, joining one already running, and rejects before touching any file
|
||||||
// when it fails. Then puts pending originals at their save paths as
|
// when it fails. Then puts pending originals at their save paths as
|
||||||
// `Photo.download()` does (and optional thumbnails) through the content
|
// `Photo.download()` does (and optional thumbnails) through the content
|
||||||
// cache and pools, waits for an ML data fetch, and rebuilds the derived
|
// cache and pools, and rebuilds the derived symlink/JSON views from the
|
||||||
// symlink/JSON views from the model, each file's JSON with its ML data.
|
// model. Throws before any network work when no content cache backs the
|
||||||
// Throws before any network work when no content cache backs the
|
|
||||||
// originals it must fetch.
|
// originals it must fetch.
|
||||||
backup(opts?: BackupOptions): Promise<BackupResult> {
|
backup(opts?: BackupOptions): Promise<BackupResult> {
|
||||||
const downloadDirectory =
|
const downloadDirectory =
|
||||||
@@ -595,8 +593,6 @@ export class Library {
|
|||||||
original: (fileID, destination) =>
|
original: (fileID, destination) =>
|
||||||
cache!.backupOriginal(fileID, destination),
|
cache!.backupOriginal(fileID, destination),
|
||||||
thumbnail: (fileID) => cache!.thumbnail(fileID),
|
thumbnail: (fileID) => cache!.thumbnail(fileID),
|
||||||
fetchMLData: () => this.fetchMLDataNow(),
|
|
||||||
mlData: (fileID) => this.mldata.forFile({ fileID }),
|
|
||||||
},
|
},
|
||||||
{ ...opts, downloadDirectory },
|
{ ...opts, downloadDirectory },
|
||||||
);
|
);
|
||||||
@@ -616,7 +612,7 @@ export class Library {
|
|||||||
this.timer = undefined;
|
this.timer = undefined;
|
||||||
}
|
}
|
||||||
await this.cycle?.catch(() => {});
|
await this.cycle?.catch(() => {});
|
||||||
await this.mlFetch?.catch(() => {});
|
await this.mlFetch;
|
||||||
await precacheClosed;
|
await precacheClosed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,9 +676,10 @@ export class Library {
|
|||||||
// Backfill ML data for the files this refresh knows about. It runs
|
// Backfill ML data for the files this refresh knows about. It runs
|
||||||
// outside the refresh's success/failure so a fetch or disk problem
|
// outside the refresh's success/failure so a fetch or disk problem
|
||||||
// there never marks the metadata refresh failed, and it is not
|
// there never marks the metadata refresh failed, and it is not
|
||||||
// awaited so it never stalls the refresh interval. Its failure is
|
// awaited so it never stalls the refresh interval.
|
||||||
// reported through `status()` and `onProgress`.
|
this.mlFetch ??= this.runMLFetch().finally(() => {
|
||||||
void this.fetchMLDataNow().catch(() => {});
|
this.mlFetch = undefined;
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
this.lastError = error;
|
this.lastError = error;
|
||||||
@@ -787,19 +784,10 @@ export class Library {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Join the running ML fetch pass, or start one when none runs. Resolves at
|
|
||||||
// once when the client cannot fetch ML data; rejects when the pass fails.
|
|
||||||
private fetchMLDataNow(): Promise<void> {
|
|
||||||
this.mlFetch ??= this.runMLFetch().finally(() => {
|
|
||||||
this.mlFetch = undefined;
|
|
||||||
});
|
|
||||||
return this.mlFetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One ML fetch pass: fetch, decrypt and store the ML data for every file
|
// One ML fetch pass: fetch, decrypt and store the ML data for every file
|
||||||
// the store knows about that is not cached (or whose `updationTime` has
|
// the store knows about that is not cached (or whose `updationTime` has
|
||||||
// advanced), through the metadata pool, and update the CLIP index. A
|
// advanced), through the metadata pool, and update the CLIP index. Guarded
|
||||||
// failure is reported through `status()` and `onProgress`, then thrown.
|
// so passes never overlap; a failure is reported, not thrown.
|
||||||
private async runMLFetch(): Promise<void> {
|
private async runMLFetch(): Promise<void> {
|
||||||
const mldata = this.mlStore;
|
const mldata = this.mlStore;
|
||||||
// Bind so the call keeps the client as its receiver when invoked
|
// Bind so the call keeps the client as its receiver when invoked
|
||||||
@@ -843,7 +831,6 @@ export class Library {
|
|||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
this.lastMLError = error;
|
this.lastMLError = error;
|
||||||
this.emit({ operation: "fetchMLData", status: "failed", error });
|
this.emit({ operation: "fetchMLData", status: "failed", error });
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import { runBackup, type BackupLibrary } from "../../src/backup.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";
|
||||||
import type { MLData } from "../../src/mldata-fetch.js";
|
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
import {
|
import {
|
||||||
asLivePhoto,
|
asLivePhoto,
|
||||||
@@ -829,94 +828,6 @@ describe("the refresh before a backup", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none.
|
|
||||||
// While `mlError` is set, every ML data request fails with it.
|
|
||||||
const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } };
|
|
||||||
|
|
||||||
class MLClient extends MockClient {
|
|
||||||
mlError?: string;
|
|
||||||
async fetchMLData(args: {
|
|
||||||
fileIDs: number[];
|
|
||||||
}): Promise<Map<number, MLData>> {
|
|
||||||
if (this.mlError) throw new Error(this.mlError);
|
|
||||||
const result = new Map<number, MLData>();
|
|
||||||
if (args.fileIDs.includes(100)) result.set(100, ML_PAYLOAD);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The JSON the backup in `outDir` wrote beside the original of `fileID`.
|
|
||||||
const fileJSON = (outDir: string, fileID: number): Record<string, unknown> =>
|
|
||||||
JSON.parse(readFileSync(saved(outDir, `${fileID}.json`), "utf-8"));
|
|
||||||
|
|
||||||
describe("ML data in each file's JSON", () => {
|
|
||||||
it("writes a file's ML data, and no ML field for a file that has none", async () => {
|
|
||||||
const lib = await openLibrary(stubSource(), new MLClient());
|
|
||||||
const outDir = join(root, "backup");
|
|
||||||
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
|
||||||
|
|
||||||
expect(result.failed).toBe(0);
|
|
||||||
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
|
|
||||||
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlData");
|
|
||||||
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlDataError");
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives each file with no cached ML data the reason when the fetch fails, and fails the run", async () => {
|
|
||||||
const client = new MLClient();
|
|
||||||
const outDir = join(root, "backup");
|
|
||||||
const first = await openLibrary(stubSource(), client);
|
|
||||||
await first.backup({ downloadDirectory: outDir });
|
|
||||||
await first.close();
|
|
||||||
|
|
||||||
client.mlError = "HTTP 503 from server";
|
|
||||||
const lib = await openLibrary(stubSource(), client);
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
|
||||||
|
|
||||||
// File 100's ML data was cached by the first run and is kept.
|
|
||||||
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
|
|
||||||
expect(fileJSON(outDir, 100)).not.toHaveProperty("mlDataError");
|
|
||||||
for (const fileID of [101, 200]) {
|
|
||||||
expect(fileJSON(outDir, fileID).mlDataError).toBe(
|
|
||||||
"HTTP 503 from server",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
expect(result.failed).toBe(2);
|
|
||||||
expect(result.errors.map((e) => [e.fileID, e.error])).toEqual([
|
|
||||||
[101, "ML data: HTTP 503 from server"],
|
|
||||||
[200, "ML data: HTTP 503 from server"],
|
|
||||||
]);
|
|
||||||
expect(Object.keys(readLedger(outDir).files)).toEqual(["101", "200"]);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes the ML data on the run after a failed fetch", async () => {
|
|
||||||
const client = new MLClient();
|
|
||||||
client.mlError = "HTTP 503 from server";
|
|
||||||
const outDir = join(root, "backup");
|
|
||||||
const first = await openLibrary(stubSource(), client);
|
|
||||||
expect((await first.backup({ downloadDirectory: outDir })).failed).toBe(
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
expect(fileJSON(outDir, 100)).not.toHaveProperty("mlData");
|
|
||||||
await first.close();
|
|
||||||
|
|
||||||
client.mlError = undefined;
|
|
||||||
const lib = await openLibrary(stubSource(), client);
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
|
||||||
|
|
||||||
expect(result.failed).toBe(0);
|
|
||||||
expect(result.skipped).toBe(3);
|
|
||||||
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
|
|
||||||
for (const fileID of [100, 101, 200]) {
|
|
||||||
expect(fileJSON(outDir, fileID)).not.toHaveProperty("mlDataError");
|
|
||||||
}
|
|
||||||
expect(existsSync(join(outDir, "failures.json"))).toBe(false);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Every entry under collections/, one level of directories deep, with each
|
// Every entry under collections/, one level of directories deep, with each
|
||||||
// symlink's target.
|
// symlink's target.
|
||||||
const tree = (outDir: string): string[] => {
|
const tree = (outDir: string): string[] => {
|
||||||
@@ -960,8 +871,6 @@ describe("backup album folders", () => {
|
|||||||
thumbnail: async () => {
|
thumbnail: async () => {
|
||||||
throw new Error("no thumbnails in this stand-in");
|
throw new Error("no thumbnails in this stand-in");
|
||||||
},
|
},
|
||||||
fetchMLData: async () => {},
|
|
||||||
mlData: async () => undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const albumID = (outDir: string, jsonName: string): number =>
|
const albumID = (outDir: string, jsonName: string): number =>
|
||||||
|
|||||||
@@ -732,22 +732,6 @@ describe("backup", () => {
|
|||||||
expect(stderr.text).toBe("Starting backup...\n");
|
expect(stderr.text).toBe("Starting backup...\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exits 1 and lists each file when the ML data fetch fails", async () => {
|
|
||||||
const client = {
|
|
||||||
...fakeClient(),
|
|
||||||
fetchMLData: async () => {
|
|
||||||
throw new Error("HTTP 503 from server");
|
|
||||||
},
|
|
||||||
} as unknown as Client;
|
|
||||||
expect(
|
|
||||||
await backupCommand(context(client), join(root, "backup"), {}),
|
|
||||||
).toBe(1);
|
|
||||||
expect(stderr.text).toContain(" Failed: 3\n");
|
|
||||||
expect(stderr.text).toContain(
|
|
||||||
" [Vacation] beach.jpg (id 100): ML data: HTTP 503 from server\n",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exits 1 with the error on one line when the refresh fails", async () => {
|
it("exits 1 with the error on one line when the refresh fails", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
...fakeClient(),
|
...fakeClient(),
|
||||||
|
|||||||
Reference in New Issue
Block a user