Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141) #142
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user