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
3 changed files with 64 additions and 16 deletions
Showing only changes of commit 2323da4727 - Show all commits
+2 -2
View File
@@ -44,8 +44,8 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
// 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. `file` is // 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 // the membership the record is read from, so the save path carries the date of
// refresh removes the file from the library. // `takenAt` and 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,
+18 -14
View File
@@ -86,9 +86,9 @@ 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 // The membership each photo's record is read from, for its `Photo`'s save
// holds the file's key, so it stays in this process: no snapshot or change // path. It holds the file's key, so it stays in this process: no snapshot
// carries it. // or change carries it.
files: Map<number, EnteFile>; files: Map<number, EnteFile>;
} }
@@ -113,10 +113,20 @@ export const takenAtOf = (file: EnteFile): number =>
file.metadata.creationTime, file.metadata.creationTime,
); );
// The membership a file's record is read from: the most recently synced, lowest
// collection id to break ties.
const representative = (memberships: EnteFile[]): EnteFile =>
memberships.reduce((best, m) =>
m.updationTime > best.updationTime ||
(m.updationTime === best.updationTime &&
m.collectionID < best.collectionID)
? m
: best,
);
// Build one PhotoRecord from every membership of a file. The memberships share // Build one PhotoRecord from every membership of a file. The memberships share
// the same underlying file, so metadata is read from a single representative // the same underlying file, so metadata is read from a single representative;
// (the most recently synced, lowest collection id to break ties); `albumIDs` // `albumIDs` gathers them all.
// gathers them all.
const toPhotoRecord = ( const toPhotoRecord = (
fileID: number, fileID: number,
memberships: EnteFile[], memberships: EnteFile[],
@@ -124,13 +134,7 @@ const toPhotoRecord = (
const albumIDs = memberships const albumIDs = memberships
.map((m) => m.collectionID) .map((m) => m.collectionID)
.sort((a, b) => a - b); .sort((a, b) => a - b);
const rep = memberships.reduce((best, m) => const rep = representative(memberships);
m.updationTime > best.updationTime ||
(m.updationTime === best.updationTime &&
m.collectionID < best.collectionID)
? m
: best,
);
const pub = rep.pubMagicMetadata ?? {}; const pub = rep.pubMagicMetadata ?? {};
const priv = rep.magicMetadata ?? {}; const priv = rep.magicMetadata ?? {};
@@ -221,7 +225,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]!); photoFiles.set(fileID, representative(memberships));
takenAtByFile.set(fileID, record.takenAt); takenAtByFile.set(fileID, record.takenAt);
} }
+44
View File
@@ -416,6 +416,50 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close(); await lib.close();
}); });
it("dates the save path 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);
older.pubMagicMetadata = {
editedTime: new Date(2026, 1, 1, 12).getTime() * 1000,
};
const newer = file(1, 2);
newer.updationTime = 2;
newer.pubMagicMetadata = {
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
};
const client = {
whoami: () => ({ email: "u@example.com", userID: USER_ID }),
collectionsSince: async (): Promise<CollectionsPage> => ({
collections: [collection(1), collection(2)],
deleted: [],
cursor: 1,
}),
filesSince: async (args: {
collectionID: number;
}): Promise<FilesPage> => ({
files: [args.collectionID === 1 ? older : newer],
deleted: [],
cursor: 1,
}),
};
const lib = await open({ client });
const photo = lib.photos.byID({ fileID: 1 })!;
expect(photo.takenAt).toBe(new Date(2026, 3, 15, 12).getTime());
expect(photo.savePath).toBe(
join(
root,
"backup",
"2026",
"2026-04",
"2026-04-15",
"2026-04-15.1.jpg",
),
);
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 })!;