Save path ./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.fileID.ext; download() from the cache first (closes #143) #146
@@ -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: `photo.exif()` and `backup-metadata --exif` read EXIF from
|
- 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from
|
||||||
HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and
|
HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and
|
||||||
|
|||||||
+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 {
|
||||||
@@ -208,7 +211,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;
|
||||||
}
|
}
|
||||||
@@ -302,8 +308,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) => {
|
||||||
|
|||||||
@@ -388,6 +388,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