From e7f75685e512fd45f44eac22b1b4a5450c64f589 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 15:03:08 +0000 Subject: [PATCH 1/4] Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141) A Photo now has savePath and isLocal, which look only at the disk; content() and exif(), which may download the original; and modifiedAt, hash, fileSize and year. PhotoRecord gains modifiedAt, hash and fileSize. The JPEG EXIF scan moves from metadata-backup.ts to the new src/exif.ts, so the read surface does not import the backup command. Model: opus-5-5 --- README.md | 45 +++++- TODO.md | 9 ++ src/exif.ts | 145 ++++++++++++++++++ src/index.ts | 1 + src/library/content.ts | 25 +++ src/library/read.ts | 66 ++++++-- src/library/records.ts | 19 ++- src/metadata-backup.ts | 55 +------ test/cli/metadata-exif.test.ts | 6 +- test/library/content-library.test.ts | 221 ++++++++++++++++++++++++++- test/library/read.test.ts | 25 +++ test/library/records.test.ts | 2 + 12 files changed, 533 insertions(+), 86 deletions(-) create mode 100644 src/exif.ts diff --git a/README.md b/README.md index 58d33c1..8d1f865 100644 --- a/README.md +++ b/README.md @@ -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,40 @@ 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/.` under the `downloadDirectory` the library + was opened with, whether or not it is there yet. For a live photo the backup + has stored, it is the image's path, whose extension comes from inside the live + photo and can differ from the title's. `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, or the backup, 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. 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 +738,13 @@ 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) and `fileSize` (the original's size in + bytes, as the server reports it), `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 +839,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` diff --git a/TODO.md b/TODO.md index 5799b9a..38e8033 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,15 @@ declares one. # Completed Steps +- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`, + `modifiedAt`, `hash`, `fileSize` and `year` (issue 141). `savePath` is where + `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 + 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 + video. `PhotoRecord` gains `modifiedAt`, `hash` and `fileSize`, and the JPEG + EXIF scan moved to `src/exif.ts`. + - 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 manager squash-merges a reviewed pull request into `next`, and that only sneak diff --git a/src/exif.ts b/src/exif.ts new file mode 100644 index 0000000..778cbfa --- /dev/null +++ b/src/exif.ts @@ -0,0 +1,145 @@ +// EXIF in a JPEG's bytes. `backup-metadata --exif` records the whole EXIF block +// it finds; `Photo.exif()` returns the common fields picked from it here. + +import exifReader from "exif-reader"; + +// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment +// data, starting at the "Exif\0\0" header) when there is one, nothing when the +// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is +// malformed. Each segment length is checked against the bytes that remain and +// each step moves forward by at least 4 bytes, so the scan ends on any input. +export const extractExifFromJpeg = ( + buf: Uint8Array, +): { exif?: Buffer; error?: string } => { + if (buf[0] !== 0xff || buf[1] !== 0xd8) return {}; + let offset = 2; + while (offset < buf.length) { + if (offset + 2 > buf.length) + return { error: `truncated segment marker at byte ${offset}` }; + if (buf[offset] !== 0xff) + return { error: `no segment marker at byte ${offset}` }; + const marker = buf[offset + 1]!; + if (marker === 0xda) return {}; // start of scan, no more markers + if (offset + 4 > buf.length) + return { error: `truncated segment length at byte ${offset}` }; + const len = (buf[offset + 2]! << 8) | buf[offset + 3]!; + // The length counts its own two bytes, so anything under 2 is invalid. + if (len < 2) + return { + error: `segment length ${len} at byte ${offset} is too small`, + }; + if (offset + 2 + len > buf.length) + return { + error: `segment length ${len} at byte ${offset} runs past the end of the file`, + }; + if (marker === 0xe1) { + // APP1 — check for "Exif\0\0" header. A length under 8 cannot hold + // the six-byte header, so the segment is not EXIF; below 6 the + // bytes compared would also lie past the segment. + if ( + len >= 8 && + buf[offset + 4] === 0x45 && + buf[offset + 5] === 0x78 && + buf[offset + 6] === 0x69 && + buf[offset + 7] === 0x66 + ) { + return { + exif: Buffer.from( + buf.buffer, + buf.byteOffset + offset + 4, + len - 2, + ), + }; + } + } + offset += 2 + len; + } + return { error: "file ends before the image data" }; +}; + +// The common EXIF fields of an original. Each is absent when the file lacks it. +export interface PhotoExif { + make?: string; + model?: string; + lensModel?: string; + // When the photo was taken, by the camera's clock. EXIF writes this as text + // with no time zone, and exif-reader reads that text as if it were UTC: the + // Date's UTC fields are the clock reading, which is the moment it was taken + // only when the clock was set to UTC. + dateTimeOriginal?: Date; + // The camera clock's offset from UTC, such as "+02:00". + offsetTimeOriginal?: string; + // Seconds. + exposureTime?: number; + fNumber?: number; + iso?: number; + // Millimetres. + focalLength?: number; + // The EXIF orientation code, 1 to 8. + orientation?: number; + // Decimal degrees, negative south of the equator and west of Greenwich. + gpsLatitude?: number; + gpsLongitude?: number; + // Metres, negative below sea level. + gpsAltitude?: number; +} + +const asString = (v: unknown): string | undefined => + typeof v === "string" && v.length > 0 ? v : undefined; + +const asNumber = (v: unknown): number | undefined => + typeof v === "number" && Number.isFinite(v) ? v : undefined; + +const asDate = (v: unknown): Date | undefined => + v instanceof Date && !Number.isNaN(v.getTime()) ? v : undefined; + +// EXIF writes a GPS coordinate as three numbers: degrees, minutes and seconds. +// This is them as decimal degrees, negated when `negative`. +const asDegrees = (v: unknown, negative: boolean): number | undefined => { + if (!Array.isArray(v) || v.length !== 3) return undefined; + const [d, m, s] = v.map(asNumber); + if (d === undefined || m === undefined || s === undefined) return undefined; + const degrees = d + m / 60 + s / 3600; + return negative ? -degrees : degrees; +}; + +// The common fields of a JPEG's EXIF block: `{}` when the bytes are not a JPEG, +// have no EXIF block, or exif-reader cannot parse it. +export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { + const { exif } = extractExifFromJpeg(bytes); + if (exif === undefined) return {}; + let tags: ReturnType; + try { + tags = exifReader(exif); + } catch { + return {}; + } + const image = tags.Image ?? {}; + const photo = tags.Photo ?? {}; + const gps = tags.GPSInfo ?? {}; + const altitude = asNumber(gps.GPSAltitude); + const fields: PhotoExif = { + make: asString(image.Make), + model: asString(image.Model), + lensModel: asString(photo.LensModel), + dateTimeOriginal: asDate(photo.DateTimeOriginal), + offsetTimeOriginal: asString(photo.OffsetTimeOriginal), + exposureTime: asNumber(photo.ExposureTime), + fNumber: asNumber(photo.FNumber), + iso: asNumber(photo.ISOSpeedRatings), + focalLength: asNumber(photo.FocalLength), + orientation: asNumber(image.Orientation), + gpsLatitude: asDegrees(gps.GPSLatitude, gps.GPSLatitudeRef === "S"), + gpsLongitude: asDegrees(gps.GPSLongitude, gps.GPSLongitudeRef === "W"), + // A GPSAltitudeRef of 1 means the altitude is below sea level. + gpsAltitude: + altitude !== undefined && gps.GPSAltitudeRef === 1 + ? -altitude + : altitude, + }; + // Leave out what the file lacks, so a missing field is absent rather than + // present and undefined. + return Object.fromEntries( + Object.entries(fields).filter(([, v]) => v !== undefined), + ) as PhotoExif; +}; diff --git a/src/index.ts b/src/index.ts index a0a4186..b5ebfe1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,6 +84,7 @@ export type { LibrarySnapshot, LibraryChange, } from "./library/records.js"; +export type { PhotoExif } from "./exif.js"; export { decryptCollection, decryptFile } from "./model/index.js"; export { downloadFile, downloadThumbnail } from "./download/index.js"; export type { diff --git a/src/library/content.ts b/src/library/content.ts index 2353fef..5b8805a 100644 --- a/src/library/content.ts +++ b/src/library/content.ts @@ -108,6 +108,8 @@ export interface ContentOptions { export interface PhotoContent { original(fileID: number, opts?: ContentOptions): Promise; thumbnail(fileID: number, opts?: ContentOptions): Promise; + savePath(fileID: number): string | undefined; + isLocal(fileID: number): boolean; } export interface EnsureResult { @@ -435,6 +437,29 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress); } + // Where a backup to the download directory stores the file's original, + // whether or not it is there yet: for a live photo already stored, its + // image. Undefined with no download directory. + savePath(fileID: number): string | undefined { + const file = this.getFile(fileID); + if (this.downloadDirectory === undefined || file === undefined) + return undefined; + const dir = join(this.downloadDirectory, "originals"); + return ( + storedOriginal(dir, file)?.path ?? join(dir, nameInOriginals(file)) + ); + } + + // Whether the whole original is in the download directory, as a backup + // stores it. A copy only in the cache does not count. + isLocal(fileID: number): boolean { + const file = this.getFile(fileID); + if (this.downloadDirectory === undefined || file === undefined) + return false; + const dir = join(this.downloadDirectory, "originals"); + return storedOriginal(dir, file) !== undefined; + } + // Get an original for a backup. One not present anywhere is written // straight to `destination` and recorded there, so no second copy lands // in the cache; one already present is returned where it is. diff --git a/src/library/read.ts b/src/library/read.ts index 09b95cf..af9b354 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -11,11 +11,15 @@ // access and, for an album, its photos. They are not sent across IPC — the // plain records are the serializable surface, and `record()` returns one. // -// A `Photo` also fetches its own bytes: `original()` and `thumbnail()` go -// through the on-disk content cache (issue #46), the one place in this module -// that is not synchronous and RAM-only. A library opened without a content -// source leaves that cache absent, and those two methods then throw. +// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`, +// `content()` and `exif()` go through the on-disk content cache (issue #46), +// and are the one place in this module that may touch the network. A library +// opened without a content source leaves that cache absent, and those methods +// then throw. `savePath` and `isLocal` look only at the disk. +import { readFile } from "node:fs/promises"; + +import { readPhotoExif, type PhotoExif } from "../exif.js"; import type { CollectionType, FileType } from "../model/types.js"; import type { ContentOptions, ContentResult, PhotoContent } from "./content.js"; import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js"; @@ -35,7 +39,7 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number => export class Photo { constructor( private readonly rec: PhotoRecord, - private readonly content?: PhotoContent, + private readonly cache?: PhotoContent, ) {} get fileID(): number { @@ -50,6 +54,13 @@ export class Photo { get takenAt(): number { return this.rec.takenAt; } + get modifiedAt(): number { + return this.rec.modifiedAt; + } + // The local-time year of `takenAt`. + get year(): number { + return new Date(this.rec.takenAt).getFullYear(); + } get fileType(): FileType { return this.rec.fileType; } @@ -68,6 +79,12 @@ export class Photo { get longitude(): number | undefined { return this.rec.longitude; } + get hash(): string | undefined { + return this.rec.hash; + } + get fileSize(): number | undefined { + return this.rec.fileSize; + } get isArchived(): boolean { return this.rec.isArchived; } @@ -75,6 +92,20 @@ export class Photo { return this.rec.isHidden; } + // The path `lib.backup()` writes the original to in the library's download + // directory, whether or not it is there yet; for a live photo, its image. + // Undefined when the library has no download directory or no content + // cache. + get savePath(): string | undefined { + return this.cache?.savePath(this.rec.fileID); + } + + // Whether the whole original is at `savePath`. A copy only in the cache + // does not count. + get isLocal(): boolean { + return this.cache?.isLocal(this.rec.fileID) ?? false; + } + record(): PhotoRecord { return this.rec; } @@ -84,21 +115,36 @@ export class Photo { // `videoPath`. Served from the cache (or the backup download directory) // when already present, otherwise fetched through the content pool. async original(opts?: ContentOptions): Promise { - return this.contentOrThrow().original(this.rec.fileID, opts); + return this.cacheOrThrow().original(this.rec.fileID, opts); } // As `original`, for the thumbnail, through the thumbnail pool. async thumbnail(opts?: ContentOptions): Promise { - return this.contentOrThrow().thumbnail(this.rec.fileID, opts); + return this.cacheOrThrow().thumbnail(this.rec.fileID, opts); } - private contentOrThrow(): PhotoContent { - if (!this.content) { + // The original's bytes, read from where `original()` puts it. For a live + // photo, its image's. + async content(opts?: ContentOptions): Promise { + const { path } = await this.original(opts); + return readFile(path); + } + + // 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 `{}`, + // and a video gives it without fetching anything. + async exif(opts?: ContentOptions): Promise { + if (this.rec.fileType === "video") return {}; + return readPhotoExif(await this.content(opts)); + } + + private cacheOrThrow(): PhotoContent { + if (!this.cache) { throw new Error( "Photo content requires a library opened with a content cache", ); } - return this.content; + return this.cache; } } diff --git a/src/library/records.ts b/src/library/records.ts index c06681f..28f0546 100644 --- a/src/library/records.ts +++ b/src/library/records.ts @@ -5,10 +5,11 @@ // owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in // the main process; the window only ever sees these records. // -// Ente holds edited/basic times in microseconds; records expose `takenAt` in -// milliseconds. The magic-metadata field names below are the ones the Ente -// clients write, confirmed against the repo's own fixtures: `w`/`h` in -// test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts. +// Ente holds edited/basic times in microseconds; records expose `takenAt` and +// `modifiedAt` in milliseconds. The magic-metadata field names below are the +// ones the Ente clients write, confirmed against the repo's own fixtures: +// `w`/`h` in test/cli/metadata-backup.test.ts, `visibility` in +// test/library/store.test.ts. import type { Collection, @@ -33,12 +34,19 @@ export interface PhotoRecord { // Milliseconds. `pubMagicMetadata.editedTime` when the user edited the // date, else basic-metadata `creationTime`. takenAt: number; + // Milliseconds. Basic-metadata `modificationTime`. + modifiedAt: number; fileType: FileType; caption?: string; width?: number; height?: number; latitude?: number; longitude?: number; + // The content hash the uploader recorded (`FileMetadata.hash`); files from + // very old clients have none. + hash?: string; + // The original's size in bytes, as the server reports it. + fileSize?: number; isArchived: boolean; isHidden: boolean; // Local cache paths, set once a later phase caches the bytes; unset here. @@ -125,6 +133,7 @@ const toPhotoRecord = ( albumIDs, title: asString(pub.editedName) ?? rep.metadata.title, takenAt: microsToMillis(takenAtMicros), + modifiedAt: microsToMillis(rep.metadata.modificationTime), fileType: rep.metadata.fileType, isArchived: visibility === VISIBILITY_ARCHIVED, isHidden: visibility === VISIBILITY_HIDDEN, @@ -140,6 +149,8 @@ const toPhotoRecord = ( record.latitude = rep.metadata.latitude; if (rep.metadata.longitude !== undefined) record.longitude = rep.metadata.longitude; + if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash; + if (rep.file.size !== undefined) record.fileSize = rep.file.size; return record; }; diff --git a/src/metadata-backup.ts b/src/metadata-backup.ts index 2bee63e..420fb9e 100644 --- a/src/metadata-backup.ts +++ b/src/metadata-backup.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import * as jpeg from "jpeg-js"; import exifReader from "exif-reader"; import type { Client } from "./client.js"; +import { extractExifFromJpeg } from "./exif.js"; import type { Library, Photo } from "./library/index.js"; import { sanitizeFileName } from "./filename.js"; import { @@ -19,60 +20,6 @@ export interface MetadataBackupOptions { onProgress?: ProgressCallback; } -// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment -// data, starting at the "Exif\0\0" header) when there is one, nothing when the -// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is -// malformed. Each segment length is checked against the bytes that remain and -// each step moves forward by at least 4 bytes, so the scan ends on any input. -export const extractExifFromJpeg = ( - buf: Uint8Array, -): { exif?: Buffer; error?: string } => { - if (buf[0] !== 0xff || buf[1] !== 0xd8) return {}; - let offset = 2; - while (offset < buf.length) { - if (offset + 2 > buf.length) - return { error: `truncated segment marker at byte ${offset}` }; - if (buf[offset] !== 0xff) - return { error: `no segment marker at byte ${offset}` }; - const marker = buf[offset + 1]!; - if (marker === 0xda) return {}; // start of scan, no more markers - if (offset + 4 > buf.length) - return { error: `truncated segment length at byte ${offset}` }; - const len = (buf[offset + 2]! << 8) | buf[offset + 3]!; - // The length counts its own two bytes, so anything under 2 is invalid. - if (len < 2) - return { - error: `segment length ${len} at byte ${offset} is too small`, - }; - if (offset + 2 + len > buf.length) - return { - error: `segment length ${len} at byte ${offset} runs past the end of the file`, - }; - if (marker === 0xe1) { - // APP1 — check for "Exif\0\0" header. A length under 8 cannot hold - // the six-byte header, so the segment is not EXIF; below 6 the - // bytes compared would also lie past the segment. - if ( - len >= 8 && - buf[offset + 4] === 0x45 && - buf[offset + 5] === 0x78 && - buf[offset + 6] === 0x69 && - buf[offset + 7] === 0x66 - ) { - return { - exif: Buffer.from( - buf.buffer, - buf.byteOffset + offset + 4, - len - 2, - ), - }; - } - } - offset += 2 + len; - } - return { error: "file ends before the image data" }; -}; - // Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment // is malformed or cannot be parsed, the record carries the reason in // `exifError`. diff --git a/test/cli/metadata-exif.test.ts b/test/cli/metadata-exif.test.ts index 4a8cf55..870826c 100644 --- a/test/cli/metadata-exif.test.ts +++ b/test/cli/metadata-exif.test.ts @@ -8,10 +8,8 @@ */ import { describe, expect, it } from "vitest"; -import { - extractExifFromJpeg, - extractImageMetadata, -} from "../../src/metadata-backup.js"; +import { extractExifFromJpeg } from "../../src/exif.js"; +import { extractImageMetadata } from "../../src/metadata-backup.js"; const SOI = [0xff, 0xd8]; // start of image const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index bebc9c4..a1264a7 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -6,7 +6,8 @@ * `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and * a cached path shows up on the projected record. A library opened without a * content source leaves those methods throwing rather than silently doing - * nothing. + * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()` and + * `exif()`. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; @@ -24,7 +25,7 @@ import { Library, type LibraryOptions } from "../../src/library/index.js"; import type { ContentSource } from "../../src/library/content.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; -import { asLivePhoto, cdnSource, livePhotoZip } from "../live-photo.js"; +import { asLivePhoto, cdnSource, IMAGE, livePhotoZip } from "../live-photo.js"; const USER_ID = 7; @@ -70,14 +71,23 @@ class MockClient { } } -// A content source that writes a marker file and counts thumbnail fetches. -const stubSource = (): ContentSource & { thumbCalls: () => number } => { +// A content source that writes `original` as every original and a marker file +// as every thumbnail, and counts the fetches of each. +const stubSource = ( + original: string | Uint8Array = "orig-bytes", +): ContentSource & { + originalCalls: () => number; + thumbCalls: () => number; +} => { + let originalCalls = 0; let thumbCalls = 0; return { + originalCalls: () => originalCalls, thumbCalls: () => thumbCalls, original: async ({ destination }) => { - writeFileSync(destination, "orig-bytes"); - return { bytesWritten: 10 }; + originalCalls++; + writeFileSync(destination, original); + return { bytesWritten: original.length }; }, thumbnail: async ({ destination }) => { thumbCalls++; @@ -211,3 +221,202 @@ describe("Library content wiring", () => { await lib.close(); }); }); + +// Big-endian bytes for the hand-built JPEG below. +const u16 = (n: number): number[] => [n >> 8, n & 0xff]; +const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)]; +const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0]; +const rational = (num: number, den: number): number[] => [ + ...u32(num), + ...u32(den), +]; +// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL), +// count, then the value when it fits in 4 bytes, else its offset. +const entry = ( + tag: number, + type: number, + count: number, + value: number[], +): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value]; + +// The TIFF block of a JPEG's EXIF segment, holding every field `exif()` picks: +// the camera in the first IFD, the exposure in the Exif IFD, and a GPS position +// of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. Offsets count from the +// start of this block. +const TIFF = [ + ...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF + ...u32(8), // the first IFD's offset + // The first IFD, at 8: five entries, then no next IFD. + ...u16(5), + ...entry(0x010f, 2, 6, u32(74)), // Make + ...entry(0x0110, 2, 7, u32(80)), // Model + ...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation + ...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset + ...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset + ...u32(0), + ...ascii("Canon"), // at 74 + ...ascii("EOS R5"), // at 80 + 0, // a pad byte + // The Exif IFD, at 88: seven entries, then no next IFD. + ...u16(7), + ...entry(0x829a, 5, 1, u32(178)), // ExposureTime + ...entry(0x829d, 5, 1, u32(186)), // FNumber + ...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings + ...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal + ...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal + ...entry(0x920a, 5, 1, u32(222)), // FocalLength + ...entry(0xa434, 2, 16, u32(230)), // LensModel + ...u32(0), + ...rational(1, 250), // at 178 + ...rational(28, 10), // at 186 + ...ascii("2021:07:15 14:30:00"), // at 194 + ...ascii("+02:00"), // at 214 + 0, // a pad byte + ...rational(50, 1), // at 222 + ...ascii("RF50mm F1.8 STM"), // at 230 + // The GPS IFD, at 246: six entries, then no next IFD. + ...u16(6), + ...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef + ...entry(0x0002, 5, 3, u32(324)), // GPSLatitude + ...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef + ...entry(0x0004, 5, 3, u32(348)), // GPSLongitude + ...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level + ...entry(0x0006, 5, 1, u32(372)), // GPSAltitude + ...u32(0), + ...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324 + ...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348 + ...rational(25, 2), // at 372 +]; + +const JPEG_WITH_EXIF = new Uint8Array([ + ...[0xff, 0xd8], // start of image + ...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length + ...[...ascii("Exif"), 0], // "Exif\0\0" + ...TIFF, + ...[0xff, 0xda, 0x00, 0x02], // start of scan +]); + +describe("Photo save path, local copy, content and EXIF", () => { + // The same account, with `files` in its album instead. + class FilesClient extends MockClient { + constructor(private readonly files: EnteFile[]) { + super(); + } + override async filesSince(): Promise { + return { files: this.files, deleted: [], cursor: 1 }; + } + } + + const open = (opts: Partial = {}): Promise => + Library.open({ + client: new MockClient(), + cacheDirectory: join(root, "cache"), + downloadDirectory: join(root, "backup"), + contentSource: stubSource(), + refreshIntervalSeconds: 3600, + precacheThumbnails: false, + precacheOriginals: false, + ...opts, + }); + + it("names where a backup writes the original, which is local once the backup has written it", async () => { + const lib = await open(); + const photo = lib.photos.byID({ fileID: 1 })!; + const savePath = join(root, "backup", "originals", "1.jpg"); + expect(photo.savePath).toBe(savePath); + expect(photo.isLocal).toBe(false); + + await lib.backup(); + expect(photo.savePath).toBe(savePath); + expect(existsSync(savePath)).toBe(true); + expect(photo.isLocal).toBe(true); + await lib.close(); + }); + + it("returns the original's bytes, and a copy only in the cache is not local", async () => { + const lib = await open(); + const photo = lib.photos.byID({ fileID: 1 })!; + expect(await photo.content()).toEqual(Buffer.from("orig-bytes")); + expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe( + true, + ); + expect(existsSync(photo.savePath!)).toBe(false); + expect(photo.isLocal).toBe(false); + await lib.close(); + }); + + it("has no save path and is not local without a download directory", async () => { + const lib = await open({ downloadDirectory: undefined }); + const photo = lib.photos.byID({ fileID: 1 })!; + expect(photo.savePath).toBeUndefined(); + expect(photo.isLocal).toBe(false); + await lib.close(); + }); + + it("has no save path and is not local without a content source", async () => { + const lib = await open({ contentSource: undefined }); + const photo = lib.photos.byID({ fileID: 1 })!; + expect(photo.savePath).toBeUndefined(); + expect(photo.isLocal).toBe(false); + await lib.close(); + }); + + it("gives a live photo's image as its save path once a backup has stored it", async () => { + const { file: live, body } = await asLivePhoto(file(1, 1)); + const lib = await open({ + client: new FilesClient([live]), + contentSource: cdnSource(new Map([[1, body]])), + }); + const photo = lib.photos.byID({ fileID: 1 })!; + const originals = join(root, "backup", "originals"); + // Until then the name comes from the title, file-1.jpg; the backup + // stores the image with the extension found inside the live photo. + expect(photo.savePath).toBe(join(originals, "1.jpg")); + + await lib.backup(); + expect(photo.savePath).toBe(join(originals, "1.heic")); + expect(photo.isLocal).toBe(true); + expect(await photo.content()).toEqual(Buffer.from(IMAGE)); + await lib.close(); + }); + + it("reads the common EXIF fields of a JPEG original", async () => { + const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) }); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({ + make: "Canon", + model: "EOS R5", + lensModel: "RF50mm F1.8 STM", + // The camera's clock reading, held in the Date's UTC fields. + dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)), + offsetTimeOriginal: "+02:00", + exposureTime: 1 / 250, + fNumber: 2.8, + iso: 400, + focalLength: 50, + orientation: 6, + gpsLatitude: 40 + 26 / 60 + 46 / 3600, + gpsLongitude: -(79 + 58 / 60 + 56 / 3600), + gpsAltitude: -12.5, + }); + await lib.close(); + }); + + it("returns no EXIF fields for an original that is not a JPEG", async () => { + const lib = await open(); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({}); + await lib.close(); + }); + + it("returns no EXIF fields for a video, without fetching it", async () => { + const video = file(1, 1); + video.metadata.fileType = "video"; + const source = stubSource(JPEG_WITH_EXIF); + const lib = await open({ + client: new FilesClient([video]), + contentSource: source, + }); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({}); + expect(source.originalCalls()).toBe(0); + await lib.close(); + }); +}); diff --git a/test/library/read.test.ts b/test/library/read.test.ts index 2fe394a..4d3c1e7 100644 --- a/test/library/read.test.ts +++ b/test/library/read.test.ts @@ -209,6 +209,31 @@ describe("lib.photos", () => { expect("key" in photo.record()).toBe(false); }); + it("byID exposes modifiedAt, hash, fileSize and the year taken", () => { + // Mid-July, so the year is 2021 in every time zone. + const takenAt = Date.UTC(2021, 6, 15, 12); + const records = deriveRecords( + [collection(1)], + [ + file(1001, 1, { + metadata: { + title: "IMG.jpg", + fileType: "image", + creationTime: micros(takenAt), + modificationTime: micros(1_700_000_123_456), + hash: "aGFzaA==", + }, + file: { decryptionHeader: "aGVhZGVy", size: 2_048_000 }, + }), + ], + ); + const photo = apis(records).photos.byID({ fileID: 1001 })!; + expect(photo.modifiedAt).toBe(ms(1_700_000_123_456)); + expect(photo.hash).toBe("aGFzaA=="); + expect(photo.fileSize).toBe(2_048_000); + expect(photo.year).toBe(2021); + }); + it("byID returns undefined for an unknown file id", () => { const records = deriveRecords([collection(1)], [file(1, 1)]); expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined(); diff --git a/test/library/records.test.ts b/test/library/records.test.ts index cf4acd3..0d0e234 100644 --- a/test/library/records.test.ts +++ b/test/library/records.test.ts @@ -159,6 +159,8 @@ describe("deriveRecords: photo mapping", () => { expect("width" in rec).toBe(false); expect("height" in rec).toBe(false); expect("latitude" 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", () => { -- 2.54.0 From ead27ff419602e95b65c94d0a9911596625db3ec Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 15:23:05 +0000 Subject: [PATCH 2/4] Photo: drop fileSize, exif() throws without a content cache Remove fileSize from Photo, PhotoRecord, README and TODO.md: the server's value is the size of the encrypted file, not the original's. exif() now checks for the content cache before it returns {} for a video, so it throws like original(), thumbnail() and content() when the library has no content source. README names the methods that also serve an original from the backup, since thumbnail() does not. New tests: exif() on a JPEG whose EXIF block cannot be parsed returns {}, and exif() on a video throws without a content source. Model: opus-5-5 --- README.md | 8 +++---- TODO.md | 6 +++--- src/library/read.ts | 7 +++---- src/library/records.ts | 3 --- test/library/content-library.test.ts | 31 ++++++++++++++++++++++++++++ test/library/read.test.ts | 4 +--- test/library/records.test.ts | 1 - 7 files changed, 42 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8d1f865..90bfe83 100644 --- a/README.md +++ b/README.md @@ -722,8 +722,9 @@ Four async methods may download: UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video gives `{}` without being downloaded. -They serve from the on-disk content cache, or the backup, when the bytes are -present and otherwise fetch through the pools; `opts.onProgress` reports +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. @@ -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` (milliseconds), `fileType`, optional `caption` / `width` / `height` / `latitude` / `longitude`, optional `hash` (the content hash recorded at - upload; very old files have none) and `fileSize` (the original's size in - bytes, as the server reports it), `isArchived`, `isHidden`, and + 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 diff --git a/TODO.md b/TODO.md index 38e8033..96f2732 100644 --- a/TODO.md +++ b/TODO.md @@ -26,13 +26,13 @@ declares one. # Completed Steps - 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 `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 fields of a JPEG; both may download the original, and `exif()` downloads no - video. `PhotoRecord` gains `modifiedAt`, `hash` and `fileSize`, and the JPEG - EXIF scan moved to `src/exif.ts`. + video. `PhotoRecord` gains `modifiedAt` and `hash`, and the JPEG EXIF scan + moved to `src/exif.ts`. - 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 diff --git a/src/library/read.ts b/src/library/read.ts index af9b354..e34abeb 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -82,9 +82,6 @@ export class Photo { get hash(): string | undefined { return this.rec.hash; } - get fileSize(): number | undefined { - return this.rec.fileSize; - } get isArchived(): boolean { return this.rec.isArchived; } @@ -132,8 +129,10 @@ export class Photo { // 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 `{}`, - // 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 { + this.cacheOrThrow(); if (this.rec.fileType === "video") return {}; return readPhotoExif(await this.content(opts)); } diff --git a/src/library/records.ts b/src/library/records.ts index 28f0546..5136ddb 100644 --- a/src/library/records.ts +++ b/src/library/records.ts @@ -45,8 +45,6 @@ export interface PhotoRecord { // The content hash the uploader recorded (`FileMetadata.hash`); files from // very old clients have none. hash?: string; - // The original's size in bytes, as the server reports it. - fileSize?: number; isArchived: boolean; isHidden: boolean; // Local cache paths, set once a later phase caches the bytes; unset here. @@ -150,7 +148,6 @@ const toPhotoRecord = ( if (rep.metadata.longitude !== undefined) record.longitude = rep.metadata.longitude; if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash; - if (rep.file.size !== undefined) record.fileSize = rep.file.size; return record; }; diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index a1264a7..debd23c 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -296,6 +296,16 @@ const JPEG_WITH_EXIF = new Uint8Array([ ...[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", () => { // The same account, with `files` in its album instead. class FilesClient extends MockClient { @@ -407,6 +417,27 @@ describe("Photo save path, local copy, content and EXIF", () => { 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 () => { const video = file(1, 1); video.metadata.fileType = "video"; diff --git a/test/library/read.test.ts b/test/library/read.test.ts index 4d3c1e7..4bb7d45 100644 --- a/test/library/read.test.ts +++ b/test/library/read.test.ts @@ -209,7 +209,7 @@ describe("lib.photos", () => { 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. const takenAt = Date.UTC(2021, 6, 15, 12); const records = deriveRecords( @@ -223,14 +223,12 @@ describe("lib.photos", () => { modificationTime: micros(1_700_000_123_456), hash: "aGFzaA==", }, - file: { decryptionHeader: "aGVhZGVy", size: 2_048_000 }, }), ], ); const photo = apis(records).photos.byID({ fileID: 1001 })!; expect(photo.modifiedAt).toBe(ms(1_700_000_123_456)); expect(photo.hash).toBe("aGFzaA=="); - expect(photo.fileSize).toBe(2_048_000); expect(photo.year).toBe(2021); }); diff --git a/test/library/records.test.ts b/test/library/records.test.ts index 0d0e234..c05b09a 100644 --- a/test/library/records.test.ts +++ b/test/library/records.test.ts @@ -160,7 +160,6 @@ describe("deriveRecords: photo mapping", () => { expect("height" in rec).toBe(false); expect("latitude" 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", () => { -- 2.54.0 From 824b2dbd764c24551a05825904f87fc8d376a1c3 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 15:35:38 +0000 Subject: [PATCH 3/4] Photo.savePath docs: a live photo not yet stored may move For a live photo the backup has not stored, savePath carries the title's extension, and the backup may store the image under the one found inside the live photo. The comment on Photo.savePath and the README now say so, as the comment on ContentCache.savePath does. Model: opus-5-5 --- README.md | 9 +++++---- src/library/read.ts | 8 +++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 90bfe83..a75f15b 100644 --- a/README.md +++ b/README.md @@ -699,10 +699,11 @@ synchronous getters look at the disk and never touch the network: - `photo.savePath` → `string | undefined` — where `lib.backup()` writes the original, `originals/.` under the `downloadDirectory` the library - was opened with, whether or not it is there yet. For a live photo the backup - has stored, it is the image's path, whose extension comes from inside the live - photo and can differ from the title's. `undefined` when the library has no - `downloadDirectory` or no content source. + 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: diff --git a/src/library/read.ts b/src/library/read.ts index e34abeb..a10731d 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -89,9 +89,11 @@ export class Photo { return this.rec.isHidden; } - // The path `lib.backup()` writes the original to in the library's download - // directory, whether or not it is there yet; for a live photo, its image. - // Undefined when the library has no download directory or no content + // Where `lib.backup()` stores the original in the library's download + // directory, 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. Undefined when the library has no download directory or no content // cache. get savePath(): string | undefined { return this.cache?.savePath(this.rec.fileID); -- 2.54.0 From af482f8eb43db63f6b18800dc4c0ed7141d5f16a Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 15:49:53 +0000 Subject: [PATCH 4/4] savePath: state the live-photo caveat in content.ts and TODO.md The comment on ContentCache.savePath, a new comment on PhotoContent.savePath, and the TODO.md entry now say that for a live photo not yet stored, savePath carries the title's extension, and the backup may store the image under a different one. Model: opus-5-5 --- TODO.md | 13 +++++++------ src/library/content.ts | 7 ++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index 96f2732..4c188b5 100644 --- a/TODO.md +++ b/TODO.md @@ -27,12 +27,13 @@ declares one. - 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`, `modifiedAt`, `hash` and `year` (issue 141). `savePath` is where - `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 - 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 - video. `PhotoRecord` gains `modifiedAt` and `hash`, and the JPEG EXIF scan - moved to `src/exif.ts`. + `lib.backup()` writes the original under the library's download directory; for + a live photo not yet stored, it carries the title's extension, and the backup + may store the image under a different one. `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 fields of a JPEG; both may + download the original, and `exif()` downloads no video. `PhotoRecord` gains + `modifiedAt` and `hash`, and the JPEG EXIF scan moved to `src/exif.ts`. - 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 diff --git a/src/library/content.ts b/src/library/content.ts index 5b8805a..fea3b74 100644 --- a/src/library/content.ts +++ b/src/library/content.ts @@ -108,6 +108,9 @@ export interface ContentOptions { export interface PhotoContent { original(fileID: number, opts?: ContentOptions): Promise; thumbnail(fileID: number, opts?: ContentOptions): Promise; + // Where a backup stores the original, whether or not it is there yet. For + // a live photo not yet stored, it carries the title's extension, and the + // backup may store the image under a different one. savePath(fileID: number): string | undefined; isLocal(fileID: number): boolean; } @@ -439,7 +442,9 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { // Where a backup to the download directory stores the file's original, // whether or not it is there yet: for a live photo already stored, its - // image. Undefined with no download directory. + // 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. + // Undefined with no download directory. savePath(fileID: number): string | undefined { const file = this.getFile(fileID); if (this.downloadDirectory === undefined || file === undefined) -- 2.54.0