Keep a Photo's save path after its file leaves; refuse an empty download directory
check / check (push) Successful in 1m31s
check / check (push) Successful in 1m31s
A Photo now keeps the file its record was made from (the record projection holds one membership of each file), so savePath and isLocal still answer after a refresh removes the file. Library.open rejects an empty downloadDirectory. A backup clears leftover temp files in every date folder under its directory, not only in those of the files in its scope. placeOriginal no longer deletes what is at the save path before copying a live photo, and the tests that depended on that are removed. The TODO.md entry for issue 143 describes only the current layout. Model: opus-5-5
This commit is contained in:
@@ -34,7 +34,7 @@ declares one.
|
|||||||
`photo.download()` puts the original there, copied from the cache when the
|
`photo.download()` puts the original there, copied from the cache when the
|
||||||
cache holds it and fetched otherwise. `lib.backup()` does the same for each
|
cache holds it and fetched otherwise. `lib.backup()` does the same for each
|
||||||
file, writes each file's JSON beside its original, and links `collections/` to
|
file, writes each file's JSON beside its original, and links `collections/` to
|
||||||
the save paths; the backup has no `originals/` folder.
|
the save paths.
|
||||||
|
|
||||||
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
|
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
|
||||||
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
|
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
|
||||||
|
|||||||
+20
-8
@@ -240,6 +240,23 @@ const linksFor = (
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Every date folder (`YYYY/YYYY-MM/YYYY-MM-DD/`) under `root`, whether or not a
|
||||||
|
// file in this backup is saved there. A folder that cannot be read is skipped.
|
||||||
|
const dateFolders = (root: string): string[] => {
|
||||||
|
const subfolders = (dir: string, name: RegExp): string[] => {
|
||||||
|
try {
|
||||||
|
return readdirSync(dir, { withFileTypes: true })
|
||||||
|
.filter((e) => e.isDirectory() && name.test(e.name))
|
||||||
|
.map((e) => join(dir, e.name));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return subfolders(root, /^\d{4}$/)
|
||||||
|
.flatMap((year) => subfolders(year, /^\d{4}-\d\d$/))
|
||||||
|
.flatMap((month) => subfolders(month, /^\d{4}-\d\d-\d\d$/));
|
||||||
|
};
|
||||||
|
|
||||||
// Whether the entry at `path` is a symlink a backup to `root` made: one to an
|
// Whether the entry at `path` is a symlink a backup to `root` made: one to an
|
||||||
// original in a `YYYY/YYYY-MM/YYYY-MM-DD/` folder of `root`.
|
// original in a `YYYY/YYYY-MM/YYYY-MM-DD/` folder of `root`.
|
||||||
const linksToOriginal = (path: string, root: string): boolean => {
|
const linksToOriginal = (path: string, root: string): boolean => {
|
||||||
@@ -358,6 +375,9 @@ export const runBackup = async (
|
|||||||
mkdirSync(collectionsDir, { recursive: true });
|
mkdirSync(collectionsDir, { recursive: true });
|
||||||
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
|
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
|
||||||
removeLeftoverTempFiles(thumbnailsDir);
|
removeLeftoverTempFiles(thumbnailsDir);
|
||||||
|
for (const dir of dateFolders(downloadDirectory)) {
|
||||||
|
removeLeftoverTempFiles(dir);
|
||||||
|
}
|
||||||
|
|
||||||
const ledgerPath = join(downloadDirectory, "failures.json");
|
const ledgerPath = join(downloadDirectory, "failures.json");
|
||||||
const ledger = loadLedger(ledgerPath);
|
const ledger = loadLedger(ledgerPath);
|
||||||
@@ -417,14 +437,6 @@ export const runBackup = async (
|
|||||||
// through the content cache/pools, as `Photo.download()` does, and fetch
|
// through the content cache/pools, as `Photo.download()` does, and fetch
|
||||||
// the optional thumbnails; a present file is left as is.
|
// the optional thumbnails; a present file is left as is.
|
||||||
if (includeOriginals) {
|
if (includeOriginals) {
|
||||||
// A killed run may have left temp files in the save path folders.
|
|
||||||
const saveDirs = new Set(
|
|
||||||
[...distinct.values()].map((f) =>
|
|
||||||
dirname(savePath(downloadDirectory, f)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
for (const dir of saveDirs) removeLeftoverTempFiles(dir);
|
|
||||||
|
|
||||||
for (const [fileID, file] of distinct) {
|
for (const [fileID, file] of distinct) {
|
||||||
if (storedAtSavePath(downloadDirectory, file) !== undefined) {
|
if (storedAtSavePath(downloadDirectory, file) !== undefined) {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
|||||||
@@ -405,10 +405,8 @@ export const copyAtomic = async (src: string, dest: string): Promise<void> => {
|
|||||||
// folders. `get` is given the save path and returns where the original is: a
|
// folders. `get` is given the save path and returns where the original is: a
|
||||||
// fetch writes it there, and a copy the cache holds is copied there. A live
|
// fetch writes it there, and a copy the cache holds is copied there. A live
|
||||||
// photo's image and video go beside the save path, each with its own
|
// photo's image and video go beside the save path, each with its own
|
||||||
// extension: when they came from the cache they are copied, after removing
|
// extension: when they came from the cache they are copied. Then the JSON file
|
||||||
// whatever was at the save path (an earlier version's ZIP of the two). Then
|
// naming them is written, which is what makes the live photo count as stored.
|
||||||
// the JSON file naming them is written, which is what makes the live photo
|
|
||||||
// count as stored.
|
|
||||||
export const placeOriginal = async (
|
export const placeOriginal = async (
|
||||||
root: string,
|
root: string,
|
||||||
file: EnteFile,
|
file: EnteFile,
|
||||||
@@ -424,7 +422,6 @@ export const placeOriginal = async (
|
|||||||
const path = withExtension(dest, extname(got.path));
|
const path = withExtension(dest, extname(got.path));
|
||||||
const videoPath = withExtension(dest, extname(got.videoPath));
|
const videoPath = withExtension(dest, extname(got.videoPath));
|
||||||
if (got.path !== path) {
|
if (got.path !== path) {
|
||||||
await rm(dest, { force: true });
|
|
||||||
await copyAtomic(got.path, path);
|
await copyAtomic(got.path, path);
|
||||||
await copyAtomic(got.videoPath, videoPath);
|
await copyAtomic(got.videoPath, videoPath);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-14
@@ -328,20 +328,9 @@ export class Library {
|
|||||||
const derive = (): DerivedRecords => this.deriveNow();
|
const derive = (): DerivedRecords => this.deriveNow();
|
||||||
const root = this.downloadDirectory;
|
const root = this.downloadDirectory;
|
||||||
const saves: SavePathLookup = {
|
const saves: SavePathLookup = {
|
||||||
savePath: (fileID) => {
|
savePath: (file) =>
|
||||||
const file = this.store.getFileByID(fileID);
|
storedAtSavePath(root, file)?.path ?? savePath(root, file),
|
||||||
if (!file) throw new Error(`library: unknown file ${fileID}`);
|
isLocal: (file) => storedAtSavePath(root, file) !== undefined,
|
||||||
return (
|
|
||||||
storedAtSavePath(root, file)?.path ?? savePath(root, file)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isLocal: (fileID) => {
|
|
||||||
const file = this.store.getFileByID(fileID);
|
|
||||||
return (
|
|
||||||
file !== undefined &&
|
|
||||||
storedAtSavePath(root, file) !== undefined
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
this.albums = makeAlbumsAPI(derive, saves, this.cache);
|
this.albums = makeAlbumsAPI(derive, saves, this.cache);
|
||||||
this.photos = makePhotosAPI(derive, saves, this.cache);
|
this.photos = makePhotosAPI(derive, saves, this.cache);
|
||||||
@@ -373,6 +362,12 @@ export class Library {
|
|||||||
const { userID } = opts.client.whoami();
|
const { userID } = opts.client.whoami();
|
||||||
const cacheDirectory =
|
const cacheDirectory =
|
||||||
opts.cacheDirectory ?? defaultCacheDirectory(userID);
|
opts.cacheDirectory ?? defaultCacheDirectory(userID);
|
||||||
|
if (opts.downloadDirectory === "") {
|
||||||
|
throw new Error(
|
||||||
|
"library: downloadDirectory is empty (leave it out to save " +
|
||||||
|
"under photos/ in the working directory)",
|
||||||
|
);
|
||||||
|
}
|
||||||
const downloadDirectory = opts.downloadDirectory ?? resolve("photos");
|
const downloadDirectory = opts.downloadDirectory ?? resolve("photos");
|
||||||
const metadataPath = join(cacheDirectory, "metadata.json");
|
const metadataPath = join(cacheDirectory, "metadata.json");
|
||||||
let store = await MetadataStore.load(metadataPath);
|
let store = await MetadataStore.load(metadataPath);
|
||||||
|
|||||||
+17
-9
@@ -21,15 +21,15 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
import { readPhotoExif, type PhotoExif } from "../exif.js";
|
import { readPhotoExif, type PhotoExif } from "../exif.js";
|
||||||
import type { CollectionType, FileType } from "../model/types.js";
|
import type { CollectionType, EnteFile, FileType } from "../model/types.js";
|
||||||
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
||||||
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
||||||
|
|
||||||
// Where a photo's original is saved, and whether all of it is there. The
|
// Where a photo's original is saved, and whether all of it is there. The
|
||||||
// library answers both from the disk, with or without a content cache.
|
// library answers both from the disk, with or without a content cache.
|
||||||
export interface SavePathLookup {
|
export interface SavePathLookup {
|
||||||
savePath(fileID: number): string;
|
savePath(file: EnteFile): string;
|
||||||
isLocal(fileID: number): boolean;
|
isLocal(file: EnteFile): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
||||||
@@ -43,10 +43,13 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
|||||||
b.updationTime - a.updationTime || b.collectionID - a.collectionID;
|
b.updationTime - a.updationTime || b.collectionID - a.collectionID;
|
||||||
|
|
||||||
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
|
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
|
||||||
// underlying plain record for callers that need the IPC-safe value.
|
// underlying plain record for callers that need the IPC-safe value. `file` is
|
||||||
|
// the file the record was made from, so the save path stays known after a
|
||||||
|
// refresh removes the file from the library.
|
||||||
export class Photo {
|
export class Photo {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly rec: PhotoRecord,
|
private readonly rec: PhotoRecord,
|
||||||
|
private readonly file: EnteFile,
|
||||||
private readonly saves: SavePathLookup,
|
private readonly saves: SavePathLookup,
|
||||||
private readonly cache?: PhotoContent,
|
private readonly cache?: PhotoContent,
|
||||||
) {}
|
) {}
|
||||||
@@ -105,13 +108,13 @@ export class Photo {
|
|||||||
// the title's extension, and the image may be stored under a different
|
// the title's extension, and the image may be stored under a different
|
||||||
// one.
|
// one.
|
||||||
get savePath(): string {
|
get savePath(): string {
|
||||||
return this.saves.savePath(this.rec.fileID);
|
return this.saves.savePath(this.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether the whole original is at `savePath`. A copy only in the cache
|
// Whether the whole original is at `savePath`. A copy only in the cache
|
||||||
// does not count.
|
// does not count.
|
||||||
get isLocal(): boolean {
|
get isLocal(): boolean {
|
||||||
return this.saves.isLocal(this.rec.fileID);
|
return this.saves.isLocal(this.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
record(): PhotoRecord {
|
record(): PhotoRecord {
|
||||||
@@ -207,7 +210,10 @@ export class Album {
|
|||||||
const out: Photo[] = [];
|
const out: Photo[] = [];
|
||||||
for (const id of this.rec.fileIDs) {
|
for (const id of this.rec.fileIDs) {
|
||||||
const p = this.records.photos.get(id);
|
const p = this.records.photos.get(id);
|
||||||
if (p) out.push(new Photo(p, this.saves, this.content));
|
const file = this.records.files.get(id);
|
||||||
|
if (p && file) {
|
||||||
|
out.push(new Photo(p, file, this.saves, this.content));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -301,8 +307,10 @@ export const makePhotosAPI = (
|
|||||||
content?: PhotoContent,
|
content?: PhotoContent,
|
||||||
): PhotosAPI => ({
|
): PhotosAPI => ({
|
||||||
byID: ({ fileID }): Photo | undefined => {
|
byID: ({ fileID }): Photo | undefined => {
|
||||||
const rec = derive().photos.get(fileID);
|
const records = derive();
|
||||||
return rec ? new Photo(rec, saves, content) : undefined;
|
const rec = records.photos.get(fileID);
|
||||||
|
const file = records.files.get(fileID);
|
||||||
|
return rec && file ? new Photo(rec, file, saves, content) : undefined;
|
||||||
},
|
},
|
||||||
records: ({ fileIDs }): PhotoRecord[] => {
|
records: ({ fileIDs }): PhotoRecord[] => {
|
||||||
const { photos } = derive();
|
const { photos } = derive();
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export interface LibraryChange {
|
|||||||
export interface DerivedRecords {
|
export interface DerivedRecords {
|
||||||
albums: Map<number, AlbumRecord>;
|
albums: Map<number, AlbumRecord>;
|
||||||
photos: Map<number, PhotoRecord>;
|
photos: Map<number, PhotoRecord>;
|
||||||
|
// One membership of each photo's file, for its `Photo`'s save path. It
|
||||||
|
// holds the file's key, so it stays in this process: no snapshot or change
|
||||||
|
// carries it.
|
||||||
|
files: Map<number, EnteFile>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asString = (v: unknown): string | undefined =>
|
const asString = (v: unknown): string | undefined =>
|
||||||
@@ -205,6 +209,7 @@ export const deriveRecords = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const photos = new Map<number, PhotoRecord>();
|
const photos = new Map<number, PhotoRecord>();
|
||||||
|
const photoFiles = new Map<number, EnteFile>();
|
||||||
const takenAtByFile = new Map<number, number>();
|
const takenAtByFile = new Map<number, number>();
|
||||||
for (const [fileID, memberships] of byFileID) {
|
for (const [fileID, memberships] of byFileID) {
|
||||||
const record = toPhotoRecord(fileID, memberships);
|
const record = toPhotoRecord(fileID, memberships);
|
||||||
@@ -216,6 +221,7 @@ export const deriveRecords = (
|
|||||||
record.thumbnailPath = paths.thumbnailPath;
|
record.thumbnailPath = paths.thumbnailPath;
|
||||||
}
|
}
|
||||||
photos.set(fileID, record);
|
photos.set(fileID, record);
|
||||||
|
photoFiles.set(fileID, memberships[0]!);
|
||||||
takenAtByFile.set(fileID, record.takenAt);
|
takenAtByFile.set(fileID, record.takenAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +230,7 @@ export const deriveRecords = (
|
|||||||
albums.set(c.id, toAlbumRecord(c, files, takenAtByFile));
|
albums.set(c.id, toAlbumRecord(c, files, takenAtByFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { albums, photos };
|
return { albums, photos, files: photoFiles };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sorted, GUI-ready arrays: albums newest updated first, photos newest first.
|
// Sorted, GUI-ready arrays: albums newest updated first, photos newest first.
|
||||||
|
|||||||
+19
-53
@@ -651,6 +651,22 @@ describe("lib.backup", () => {
|
|||||||
lib.close();
|
lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes temp files left in a date folder no file in the backup is saved in", async () => {
|
||||||
|
const outDir = join(root, "backup");
|
||||||
|
// As for a file since deleted, or given another date, after a run was
|
||||||
|
// killed while writing it.
|
||||||
|
const otherDay = join(outDir, "2025", "2025-01", "2025-01-02");
|
||||||
|
mkdirSync(otherDay, { recursive: true });
|
||||||
|
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
|
||||||
|
writeFileSync(join(otherDay, `.quak-${exitedPID}-abc123.tmp`), "x");
|
||||||
|
const lib = await openLibrary(stubSource());
|
||||||
|
|
||||||
|
await lib.backup({ downloadDirectory: outDir });
|
||||||
|
|
||||||
|
expect(readdirSync(otherDay)).toEqual([]);
|
||||||
|
lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("removes leftover temp files in thumbnails/ but not those of a backup still running", async () => {
|
it("removes leftover temp files in thumbnails/ but not those of a backup still running", async () => {
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
const thumbnails = join(outDir, "thumbnails");
|
const thumbnails = join(outDir, "thumbnails");
|
||||||
@@ -1098,18 +1114,6 @@ describe("backup of live photos", () => {
|
|||||||
const open = (files: EnteFile[], bodies: Map<number, Uint8Array>) =>
|
const open = (files: EnteFile[], bodies: Map<number, Uint8Array>) =>
|
||||||
openLibrary(cdnSource(bodies), new TripClient(files));
|
openLibrary(cdnSource(bodies), new TripClient(files));
|
||||||
|
|
||||||
// What an earlier version stored for live photo 500: the ZIP under the
|
|
||||||
// image's name, and its link.
|
|
||||||
const earlierZIP = (outDir: string): void => {
|
|
||||||
mkdirSync(join(outDir, DAY), { recursive: true });
|
|
||||||
mkdirSync(join(outDir, "collections", "Trip"), { recursive: true });
|
|
||||||
writeFileSync(saved(outDir, "500.HEIC"), livePhotoZip());
|
|
||||||
symlinkSync(
|
|
||||||
linkTo("500.HEIC"),
|
|
||||||
join(outDir, "collections", "Trip", "IMG_0500.HEIC"),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stored = [
|
const stored = [
|
||||||
"2026-03-01.500.heic",
|
"2026-03-01.500.heic",
|
||||||
"2026-03-01.500.json",
|
"2026-03-01.500.json",
|
||||||
@@ -1173,30 +1177,13 @@ describe("backup of live photos", () => {
|
|||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces the ZIP an earlier version stored, and its link", async () => {
|
it("stores nothing for a live photo that fails its hash", async () => {
|
||||||
const { file: live, body } = await asLivePhoto(
|
|
||||||
file(500, 10, "IMG_0500.HEIC"),
|
|
||||||
);
|
|
||||||
const outDir = join(root, "backup");
|
|
||||||
earlierZIP(outDir);
|
|
||||||
const lib = await open([live], new Map([[500, body]]));
|
|
||||||
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
|
||||||
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
|
|
||||||
expect(tree(outDir)).toEqual(linked);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores nothing for a live photo that fails its hash, and keeps what was there", async () => {
|
|
||||||
const { file: live, body } = await asLivePhoto(
|
const { file: live, body } = await asLivePhoto(
|
||||||
file(500, 10, "IMG_0500.HEIC"),
|
file(500, 10, "IMG_0500.HEIC"),
|
||||||
livePhotoZip(),
|
livePhotoZip(),
|
||||||
"not:the recorded hash",
|
"not:the recorded hash",
|
||||||
);
|
);
|
||||||
const outDir = join(root, "backup");
|
const outDir = join(root, "backup");
|
||||||
earlierZIP(outDir);
|
|
||||||
const lib = await open([live], new Map([[500, body]]));
|
const lib = await open([live], new Map([[500, body]]));
|
||||||
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
const result = await lib.backup({ downloadDirectory: outDir });
|
||||||
@@ -1204,12 +1191,8 @@ describe("backup of live photos", () => {
|
|||||||
expect(result).toMatchObject({ downloaded: 0, failed: 1 });
|
expect(result).toMatchObject({ downloaded: 0, failed: 1 });
|
||||||
expect(result.errors.map((e) => e.fileID)).toEqual([500]);
|
expect(result.errors.map((e) => e.fileID)).toEqual([500]);
|
||||||
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
|
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
|
||||||
expect(readdirSync(join(outDir, DAY))).toEqual(["2026-03-01.500.HEIC"]);
|
expect(readdirSync(join(outDir, DAY))).toEqual([]);
|
||||||
expect(tree(outDir)).toEqual([
|
expect(tree(outDir)).toEqual(["Trip/", "Trip.json"]);
|
||||||
"Trip/",
|
|
||||||
`Trip/IMG_0500.HEIC -> ${linkTo("500.HEIC")}`,
|
|
||||||
"Trip.json",
|
|
||||||
]);
|
|
||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1234,23 +1217,6 @@ describe("backup of live photos", () => {
|
|||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces an earlier ZIP and its link with the image and video the cache holds", async () => {
|
|
||||||
const { file: live, body } = await asLivePhoto(
|
|
||||||
file(500, 10, "IMG_0500.HEIC"),
|
|
||||||
);
|
|
||||||
const lib = await open([live], new Map([[500, body]]));
|
|
||||||
await lib.photos.byID({ fileID: 500 })!.original();
|
|
||||||
const outDir = join(root, "backup");
|
|
||||||
earlierZIP(outDir);
|
|
||||||
|
|
||||||
const result = await lib.backup({ downloadDirectory: outDir });
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
|
|
||||||
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
|
|
||||||
expect(tree(outDir)).toEqual(linked);
|
|
||||||
await lib.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(["missing", "empty"])(
|
it.each(["missing", "empty"])(
|
||||||
"fetches a live photo again when the video its JSON file names is %s",
|
"fetches a live photo again when the video its JSON file names is %s",
|
||||||
async (state) => {
|
async (state) => {
|
||||||
|
|||||||
@@ -379,6 +379,34 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
|||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses an empty download directory", async () => {
|
||||||
|
await expect(open({ downloadDirectory: "" })).rejects.toThrow(
|
||||||
|
/downloadDirectory is empty/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a photo's save path after a refresh removes its file", async () => {
|
||||||
|
// The same account, whose album is deleted on the second refresh.
|
||||||
|
class AlbumDeletedClient extends MockClient {
|
||||||
|
override async collectionsSince(): Promise<CollectionsPage> {
|
||||||
|
if (!this.served) return super.collectionsSince();
|
||||||
|
return { collections: [], deleted: [1], cursor: 2 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lib = await open({ client: new AlbumDeletedClient() });
|
||||||
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
|
await photo.download();
|
||||||
|
|
||||||
|
await lib.fresh();
|
||||||
|
|
||||||
|
expect(lib.photos.byID({ fileID: 1 })).toBeUndefined();
|
||||||
|
expect(photo.savePath).toBe(
|
||||||
|
join(root, "backup", DAY, "2026-03-01.1.jpg"),
|
||||||
|
);
|
||||||
|
expect(photo.isLocal).toBe(true);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("has a save path, and is not local, without a content source", async () => {
|
it("has a save path, and is not local, without a content source", async () => {
|
||||||
const lib = await open({ contentSource: undefined });
|
const lib = await open({ contentSource: undefined });
|
||||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
|
|||||||
Reference in New Issue
Block a user