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..aa5901d 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,175 @@ 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 Make, Model, Orientation 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: four entries, then no next IFD. + ...u16(4), + ...entry(0x010f, 2, 6, u32(62)), // Make + ...entry(0x0110, 2, 7, u32(68)), // Model + ...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation + ...entry(0x8825, 4, 1, u32(76)), // the GPS IFD's offset + ...u32(0), + ...ascii("Canon"), // at 62 + ...ascii("EOS R5"), // at 68 + 0, // a pad byte + // The GPS IFD, at 76: six entries, then no next IFD. + ...u16(6), + ...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef + ...entry(0x0002, 5, 3, u32(154)), // GPSLatitude + ...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef + ...entry(0x0004, 5, 3, u32(178)), // GPSLongitude + ...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level + ...entry(0x0006, 5, 1, u32(202)), // GPSAltitude + ...u32(0), + ...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 154 + ...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 178 + ...rational(25, 2), // at 202 +]; + +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", + 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", () => {