Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141) #142

Merged
clawbot merged 4 commits from issue-141-photo-getters into next 2026-10-01 17:58:24 +02:00
7 changed files with 42 additions and 18 deletions
Showing only changes of commit ead27ff419 - Show all commits
+4 -4
View File
@@ -722,8 +722,9 @@ Four async methods may download:
UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video
gives `{}` without being downloaded. gives `{}` without being downloaded.
They serve from the on-disk content cache, or the backup, when the bytes are They serve from the on-disk content cache when the bytes are present and
present and otherwise fetch through the pools; `opts.onProgress` reports otherwise fetch through the pools; `original()`, `content()` and `exif()` also
serve an original the backup has already stored. `opts.onProgress` reports
per-file progress. They throw when the library was opened without a content per-file progress. They throw when the library was opened without a content
source. An original that `content()` or `exif()` downloads lands in the cache, source. An original that `content()` or `exif()` downloads lands in the cache,
which does not make `isLocal` true; only `lib.backup()` does. which does not make `isLocal` true; only `lib.backup()` does.
@@ -741,8 +742,7 @@ The GUI-facing records hold no key material and no binary, so they survive
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt` - `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt`
(milliseconds), `fileType`, optional `caption` / `width` / `height` / (milliseconds), `fileType`, optional `caption` / `width` / `height` /
`latitude` / `longitude`, optional `hash` (the content hash recorded at `latitude` / `longitude`, optional `hash` (the content hash recorded at
upload; very old files have none) and `fileSize` (the original's size in upload; very old files have none), `isArchived`, `isHidden`, and
bytes, as the server reports it), `isArchived`, `isHidden`, and
`thumbnailPath` / `originalPath` once the bytes are cached (for a live photo, `thumbnailPath` / `originalPath` once the bytes are cached (for a live photo,
`originalPath` is its image). `originalPath` is its image).
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and - `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
+3 -3
View File
@@ -26,13 +26,13 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`, - 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
`modifiedAt`, `hash`, `fileSize` and `year` (issue 141). `savePath` is where `modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
`lib.backup()` writes the original under the library's download directory, and `lib.backup()` writes the original under the library's download directory, and
`isLocal` says whether the whole original is there; both look only at the `isLocal` says whether the whole original is there; both look only at the
disk. `content()` returns the original's bytes and `exif()` the common EXIF disk. `content()` returns the original's bytes and `exif()` the common EXIF
fields of a JPEG; both may download the original, and `exif()` downloads no fields of a JPEG; both may download the original, and `exif()` downloads no
video. `PhotoRecord` gains `modifiedAt`, `hash` and `fileSize`, and the JPEG video. `PhotoRecord` gains `modifiedAt` and `hash`, and the JPEG EXIF scan
EXIF scan moved to `src/exif.ts`. moved to `src/exif.ts`.
- 2026-09-29: The "Workflow" list at the top of this file now says to branch - 2026-09-29: The "Workflow" list at the top of this file now says to branch
from `next` and open a pull request that targets `next`, that the repository from `next` and open a pull request that targets `next`, that the repository
+3 -4
View File
@@ -82,9 +82,6 @@ export class Photo {
get hash(): string | undefined { get hash(): string | undefined {
return this.rec.hash; return this.rec.hash;
} }
get fileSize(): number | undefined {
return this.rec.fileSize;
}
get isArchived(): boolean { get isArchived(): boolean {
return this.rec.isArchived; return this.rec.isArchived;
} }
@@ -132,8 +129,10 @@ export class Photo {
// The common EXIF fields of the original, read from `content()`, so this // The common EXIF fields of the original, read from `content()`, so this
// may download it. Only a JPEG's EXIF is read; any other file gives `{}`, // may download it. Only a JPEG's EXIF is read; any other file gives `{}`,
// and a video gives it without fetching anything. // and a video gives it without fetching anything. Like the other content
// methods, it throws when there is no content cache, video or not.
async exif(opts?: ContentOptions): Promise<PhotoExif> { async exif(opts?: ContentOptions): Promise<PhotoExif> {
this.cacheOrThrow();
if (this.rec.fileType === "video") return {}; if (this.rec.fileType === "video") return {};
return readPhotoExif(await this.content(opts)); return readPhotoExif(await this.content(opts));
} }
-3
View File
@@ -45,8 +45,6 @@ export interface PhotoRecord {
// The content hash the uploader recorded (`FileMetadata.hash`); files from // The content hash the uploader recorded (`FileMetadata.hash`); files from
// very old clients have none. // very old clients have none.
hash?: string; hash?: string;
// The original's size in bytes, as the server reports it.
fileSize?: number;
isArchived: boolean; isArchived: boolean;
isHidden: boolean; isHidden: boolean;
// Local cache paths, set once a later phase caches the bytes; unset here. // Local cache paths, set once a later phase caches the bytes; unset here.
@@ -150,7 +148,6 @@ const toPhotoRecord = (
if (rep.metadata.longitude !== undefined) if (rep.metadata.longitude !== undefined)
record.longitude = rep.metadata.longitude; record.longitude = rep.metadata.longitude;
if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash; if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash;
if (rep.file.size !== undefined) record.fileSize = rep.file.size;
return record; return record;
}; };
+31
View File
@@ -296,6 +296,16 @@ const JPEG_WITH_EXIF = new Uint8Array([
...[0xff, 0xda, 0x00, 0x02], // start of scan ...[0xff, 0xda, 0x00, 0x02], // start of scan
]); ]);
// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF
// byte order belongs, so exif-reader cannot parse it.
const JPEG_WITH_BAD_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length
...[...ascii("Exif"), 0], // "Exif\0\0"
...[0x58, 0x58], // "XX"
...[0xff, 0xda, 0x00, 0x02], // start of scan
]);
describe("Photo save path, local copy, content and EXIF", () => { describe("Photo save path, local copy, content and EXIF", () => {
// The same account, with `files` in its album instead. // The same account, with `files` in its album instead.
class FilesClient extends MockClient { class FilesClient extends MockClient {
@@ -407,6 +417,27 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close(); await lib.close();
}); });
it("returns no EXIF fields for a JPEG whose EXIF cannot be parsed", async () => {
const lib = await open({
contentSource: stubSource(JPEG_WITH_BAD_EXIF),
});
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
await lib.close();
});
it("throws from exif() on a video without a content source, as the other content methods do", async () => {
const video = file(1, 1);
video.metadata.fileType = "video";
const lib = await open({
client: new FilesClient([video]),
contentSource: undefined,
});
await expect(lib.photos.byID({ fileID: 1 })!.exif()).rejects.toThrow(
/content cache/i,
);
await lib.close();
});
it("returns no EXIF fields for a video, without fetching it", async () => { it("returns no EXIF fields for a video, without fetching it", async () => {
const video = file(1, 1); const video = file(1, 1);
video.metadata.fileType = "video"; video.metadata.fileType = "video";
+1 -3
View File
@@ -209,7 +209,7 @@ describe("lib.photos", () => {
expect("key" in photo.record()).toBe(false); expect("key" in photo.record()).toBe(false);
}); });
it("byID exposes modifiedAt, hash, fileSize and the year taken", () => { it("byID exposes modifiedAt, hash and the year taken", () => {
// Mid-July, so the year is 2021 in every time zone. // Mid-July, so the year is 2021 in every time zone.
const takenAt = Date.UTC(2021, 6, 15, 12); const takenAt = Date.UTC(2021, 6, 15, 12);
const records = deriveRecords( const records = deriveRecords(
@@ -223,14 +223,12 @@ describe("lib.photos", () => {
modificationTime: micros(1_700_000_123_456), modificationTime: micros(1_700_000_123_456),
hash: "aGFzaA==", hash: "aGFzaA==",
}, },
file: { decryptionHeader: "aGVhZGVy", size: 2_048_000 },
}), }),
], ],
); );
const photo = apis(records).photos.byID({ fileID: 1001 })!; const photo = apis(records).photos.byID({ fileID: 1001 })!;
expect(photo.modifiedAt).toBe(ms(1_700_000_123_456)); expect(photo.modifiedAt).toBe(ms(1_700_000_123_456));
expect(photo.hash).toBe("aGFzaA=="); expect(photo.hash).toBe("aGFzaA==");
expect(photo.fileSize).toBe(2_048_000);
expect(photo.year).toBe(2021); expect(photo.year).toBe(2021);
}); });
-1
View File
@@ -160,7 +160,6 @@ describe("deriveRecords: photo mapping", () => {
expect("height" in rec).toBe(false); expect("height" in rec).toBe(false);
expect("latitude" in rec).toBe(false); expect("latitude" in rec).toBe(false);
expect("hash" in rec).toBe(false); expect("hash" in rec).toBe(false);
expect("fileSize" in rec).toBe(false);
}); });
it("reads archived and hidden from private magicMetadata.visibility", () => { it("reads archived and hidden from private magicMetadata.visibility", () => {