Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141)
check / check (push) Successful in 1m24s

`Photo` gains:

- `savePath` and `isLocal`: synchronous, disk only. Where `lib.backup()` writes the original under the library's `downloadDirectory`, and whether all of it is there; a copy only in the cache does not count.
- `content()` and `exif()`: async, may download. `exif()` reads the common EXIF fields of a JPEG and returns `{}` for anything else.
- The getters `modifiedAt` and `hash`, also on `PhotoRecord`, and `year`.

For a live photo the backup has not stored yet, `savePath` carries the title's extension, and the image may be stored under a different one. The JPEG EXIF scan moved to `src/exif.ts`. The exported `PhotoContent` interface gains `savePath` and `isLocal`.

Judgement call: `iso` is read only when the file stores it as a single number.

Model: opus-5-5
This commit was merged in pull request #142.
This commit is contained in:
2026-10-01 17:58:23 +02:00
parent e6825abcdb
commit 2b598d3622
12 changed files with 566 additions and 86 deletions
+38 -8
View File
@@ -224,6 +224,7 @@ quak/
backup.ts resilient full-account backup with dedup
metadata-backup.ts
backup-metadata: the metadata quak keeps, as JSON
exif.ts EXIF read from a JPEG's bytes
mldata-fetch.ts fetch + decrypt per-file ML data
filename.ts safe file names from server metadata
errors.ts error types shared across layers
@@ -692,16 +693,42 @@ https://git.eeqj.de/sneak/quak/issues/75).
An `Album` exposes its record fields and `album.photos.list()` → `Photo[]`
(newest first). A `Photo` exposes its record fields other than `thumbnailPath`
and `originalPath`, `photo.record()` → `PhotoRecord`, and two content methods:
and `originalPath`, and `photo.year`, the local-time year of `takenAt`, all as
synchronous getters read from RAM; `photo.record()` → `PhotoRecord`. Two more
synchronous getters look at the disk and never touch the network:
- `photo.savePath` → `string | undefined` — where `lib.backup()` writes the
original, `originals/<fileID>.<ext>` under the `downloadDirectory` the library
was opened with, whether or not it is there yet: for a live photo already
stored, its image. For a live photo not yet stored, it carries the title's
extension, and the backup may store the image under a different one, found
inside the live photo. `undefined` when the library has no `downloadDirectory`
or no content source.
- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`.
Four async methods may download:
- `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
full-resolution file. For a live photo, `path` and `bytes` are its image's and
`videoPath` is its video.
- `await photo.thumbnail(opts?)` → `{ path, bytes }`.
- `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read
through `original()`; for a live photo, its image's.
- `await photo.exif(opts?)` → `PhotoExif` — `make`, `model`, `lensModel`,
`dateTimeOriginal`, `offsetTimeOriginal`, `exposureTime`, `fNumber`, `iso`,
`focalLength`, `orientation`, `gpsLatitude`, `gpsLongitude` and `gpsAltitude`,
each absent when the file lacks it. GPS values are signed decimal degrees and
metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s
UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from
UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video
gives `{}` without being downloaded.
Both serve from the on-disk content cache when the bytes are present and
otherwise fetch through the pools; `opts.onProgress` reports per-file progress.
They throw when the library was opened without a content source.
They serve from the on-disk content cache when the bytes are present and
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
source. An original that `content()` or `exif()` downloads lands in the cache,
which does not make `isLocal` true; only `lib.backup()` does.
Lower-level accessors that return decrypted model objects (which hold key
material) are also available: `listCollections()`, `getCollection(id)`,
@@ -713,10 +740,12 @@ material) are also available: `listCollections()`, `getCollection(id)`,
The GUI-facing records hold no key material and no binary, so they survive
`structuredClone`/JSON across the Electron IPC boundary:
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` (milliseconds),
`fileType`, optional `caption` / `width` / `height` / `latitude` /
`longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath`
once the bytes are cached (for a live photo, `originalPath` is its image).
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt`
(milliseconds), `fileType`, optional `caption` / `width` / `height` /
`latitude` / `longitude`, optional `hash` (the content hash recorded at
upload; very old files have none), `isArchived`, `isHidden`, and
`thumbnailPath` / `originalPath` once the bytes are cached (for a live photo,
`originalPath` is its image).
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
`fileIDs` (newest first).
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
@@ -811,6 +840,7 @@ from a very old client, is stored unchecked.
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
`LibraryChange`
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
- `src/exif.ts`: `PhotoExif`
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`