Save path ./photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.fileID.ext; download() from the cache first (closes #143) #146

Merged
clawbot merged 5 commits from issue-143-save-path-layout into next 2026-10-01 23:20:58 +02:00
8 changed files with 103 additions and 91 deletions
Showing only changes of commit 4c72e05002 - Show all commits
+1 -1
View File
@@ -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
View File
@@ -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++;
+2 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+7 -1
View File
@@ -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
View File
@@ -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) => {
+28
View File
@@ -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 })!;