diff --git a/src/backup.ts b/src/backup.ts index 75b4336..23b92c1 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -59,6 +59,7 @@ import { savePath, storedAtSavePath, } from "./library/content.js"; +import { representative } from "./library/records.js"; import type { Collection, EnteFile } from "./model/types.js"; export type ProgressCallback = (message: string) => void; @@ -384,20 +385,34 @@ export const runBackup = async ( const now = Date.now(); // Collections in scope, and the distinct files across them (a file shared - // by two albums is one original). + // by two albums is one original). Each file is the membership + // `representative` picks from all of its albums, in scope or not, so it is + // saved at the path `photo.savePath` names. const allCollections = lib.listCollections(); const collections = allCollections.filter((c) => only ? only.has(c.name) : true, ); const collectionName = new Map(); - for (const c of collections) collectionName.set(c.id, c.name); + for (const c of allCollections) collectionName.set(c.id, c.name); - const distinct = new Map(); + const memberships = new Map(); const filesByCollection = new Map(); - for (const c of collections) { + for (const c of allCollections) { const files = lib.listFiles(c.id); filesByCollection.set(c.id, files); - for (const f of files) if (!distinct.has(f.id)) distinct.set(f.id, f); + for (const f of files) { + const arr = memberships.get(f.id); + if (arr) arr.push(f); + else memberships.set(f.id, [f]); + } + } + const distinct = new Map(); + for (const c of collections) { + for (const f of filesByCollection.get(c.id)!) { + if (!distinct.has(f.id)) { + distinct.set(f.id, representative(memberships.get(f.id)!)); + } + } } const errors: BackupError[] = []; @@ -523,9 +538,14 @@ export const runBackup = async ( const colDir = join(collectionsDir, colDirName); mkdirSync(colDir, { recursive: true }); + // Every album links the one original, saved from the file's entry in + // `distinct`. const files = filesByCollection.get(c.id) ?? []; const links = files.flatMap((f) => - linksFor(f, storedAtSavePath(downloadDirectory, f)), + linksFor( + f, + storedAtSavePath(downloadDirectory, distinct.get(f.id)!), + ), ); const linkNames = uniqueNames(links, true); try { diff --git a/src/library/index.ts b/src/library/index.ts index fecfabe..8b7a98c 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -489,9 +489,10 @@ export class Library { return this.store.getFile(collectionID, fileID); } - // Any membership of a file, addressed by file id alone. A file's own - // metadata (title, creationTime) is identical across the collections it - // belongs to, so this serves the point commands that hold only a fileID. + // The membership of a file its record is read from, addressed by file id + // alone. A file's own metadata (title, creationTime) is identical across + // the collections it belongs to, so this serves the point commands that + // hold only a fileID. getFileByID(fileID: number): EnteFile | undefined { return this.store.getFileByID(fileID); } diff --git a/src/library/records.ts b/src/library/records.ts index 7b3760f..4a59e92 100644 --- a/src/library/records.ts +++ b/src/library/records.ts @@ -114,8 +114,9 @@ export const takenAtOf = (file: EnteFile): number => ); // The membership a file's record is read from: the most recently synced, lowest -// collection id to break ties. -const representative = (memberships: EnteFile[]): EnteFile => +// collection id to break ties. Whatever dates a file's save path takes this +// membership too, so the path always carries the record's `takenAt`. +export const representative = (memberships: EnteFile[]): EnteFile => memberships.reduce((best, m) => m.updationTime > best.updationTime || (m.updationTime === best.updationTime && diff --git a/src/library/store.ts b/src/library/store.ts index 56fe0dd..e74f8e1 100644 --- a/src/library/store.ts +++ b/src/library/store.ts @@ -16,6 +16,7 @@ import { dirname } from "node:path"; import { writeAtomic } from "../download/index.js"; import type { Collection, EnteFile, Microseconds } from "../model/types.js"; +import { representative } from "./records.js"; // Bumped only when the on-disk shape changes incompatibly. A file written // under a different version is discarded on load (see `load`): re-fetching @@ -179,14 +180,16 @@ export class MetadataStore { return this.files.get(fileKey(collectionID, fileID)); } - // Any membership of a file, or undefined. Every membership re-wraps the - // same underlying content key, so any one is enough to fetch the bytes; - // the content cache resolves a fileID to a file this way. + // The membership of a file its record is read from (`representative`), or + // undefined. Any membership could fetch the bytes, but the content cache + // resolves a fileID to a file this way so that it dates the save path + // from the same membership as `photo.savePath`. getFileByID(fileID: number): EnteFile | undefined { + const memberships: EnteFile[] = []; for (const file of this.files.values()) { - if (file.id === fileID) return file; + if (file.id === fileID) memberships.push(file); } - return undefined; + return memberships.length > 0 ? representative(memberships) : undefined; } listFiles(collectionID: number): EnteFile[] { diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 6135af2..57c18b9 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -608,6 +608,57 @@ describe("lib.backup", () => { await lib.close(); }); + it("saves a file in two albums at its photo's save path and links it from both", async () => { + // The date edited in Ente has reached Work's copy, synced later, but + // Vacation's copy still has an earlier edit. + const older = file(100, 1, "beach.jpg"); + older.pubMagicMetadata = { + editedTime: new Date(2026, 1, 1, 12).getTime() * 1000, + }; + const newer = file(100, 2, "beach.jpg"); + newer.updationTime = 2; + newer.pubMagicMetadata = { + editedTime: new Date(2026, 3, 15, 12).getTime() * 1000, + }; + class SharedFileClient extends MockClient { + override async filesSince(args: { + collectionID: number; + }): Promise { + const files = [args.collectionID === 1 ? older : newer]; + return { files, deleted: [], cursor: 1 }; + } + } + const outDir = join(root, "backup"); + const lib = await Library.open({ + client: new SharedFileClient(), + cacheDirectory: join(root, "cache"), + downloadDirectory: outDir, + contentSource: stubSource(), + refreshIntervalSeconds: 3600, + precacheThumbnails: false, + precacheOriginals: false, + }); + const photo = lib.photos.byID({ fileID: 100 })!; + + const result = await lib.backup(); + + expect(result.totalFiles).toBe(1); + expect(result.downloaded).toBe(1); + expect(result.failed).toBe(0); + expect(photo.savePath).toBe( + join(outDir, "2026", "2026-04", "2026-04-15", "2026-04-15.100.jpg"), + ); + expect(photo.isLocal).toBe(true); + expect(existsSync(join(outDir, "2026", "2026-02"))).toBe(false); + const target = "../../2026/2026-04/2026-04-15/2026-04-15.100.jpg"; + for (const album of ["Vacation", "Work"]) { + expect( + readlinkSync(join(outDir, "collections", album, "beach.jpg")), + ).toBe(target); + } + await lib.close(); + }); + it("fsyncs an original copied from the cache before the rename and its directory after", async () => { const lib = await openLibrary(stubSource()); const outDir = join(root, "backup"); diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index c9c7978..8fee61d 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -416,7 +416,7 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); - it("dates the save path by the membership its takenAt comes from", async () => { + it("dates the save path and download() by the membership its takenAt comes from", async () => { // One file in two albums. The date edited in Ente has reached album 2's // copy, synced later, but album 1 still has an earlier edit. const older = file(1, 1); @@ -457,6 +457,12 @@ describe("Photo save path, local copy, content and EXIF", () => { "2026-04-15.1.jpg", ), ); + + // download() writes to that same path, so the photo is then local. + const saved = await photo.download(); + expect(saved.path).toBe(photo.savePath); + expect(photo.isLocal).toBe(true); + expect(existsSync(join(root, "backup", "2026", "2026-02"))).toBe(false); await lib.close(); });