Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141)
check / check (push) Successful in 1m34s
check / check (push) Successful in 1m34s
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
This commit is contained in:
@@ -224,6 +224,7 @@ quak/
|
|||||||
backup.ts resilient full-account backup with dedup
|
backup.ts resilient full-account backup with dedup
|
||||||
metadata-backup.ts
|
metadata-backup.ts
|
||||||
backup-metadata: the metadata quak keeps, as JSON
|
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
|
mldata-fetch.ts fetch + decrypt per-file ML data
|
||||||
filename.ts safe file names from server metadata
|
filename.ts safe file names from server metadata
|
||||||
errors.ts error types shared across layers
|
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[]`
|
An `Album` exposes its record fields and `album.photos.list()` → `Photo[]`
|
||||||
(newest first). A `Photo` exposes its record fields other than `thumbnailPath`
|
(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 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
|
- `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
|
||||||
full-resolution file. For a live photo, `path` and `bytes` are its image's and
|
full-resolution file. For a live photo, `path` and `bytes` are its image's and
|
||||||
`videoPath` is its video.
|
`videoPath` is its video.
|
||||||
- `await photo.thumbnail(opts?)` → `{ path, bytes }`.
|
- `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
|
They serve from the on-disk content cache, or the backup, when the bytes are
|
||||||
otherwise fetch through the pools; `opts.onProgress` reports per-file progress.
|
present and otherwise fetch through the pools; `opts.onProgress` reports
|
||||||
They throw when the library was opened without a content source.
|
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
|
Lower-level accessors that return decrypted model objects (which hold key
|
||||||
material) are also available: `listCollections()`, `getCollection(id)`,
|
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
|
The GUI-facing records hold no key material and no binary, so they survive
|
||||||
`structuredClone`/JSON across the Electron IPC boundary:
|
`structuredClone`/JSON across the Electron IPC boundary:
|
||||||
|
|
||||||
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` (milliseconds),
|
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt`
|
||||||
`fileType`, optional `caption` / `width` / `height` / `latitude` /
|
(milliseconds), `fileType`, optional `caption` / `width` / `height` /
|
||||||
`longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath`
|
`latitude` / `longitude`, optional `hash` (the content hash recorded at
|
||||||
once the bytes are cached (for a live photo, `originalPath` is its image).
|
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
|
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
|
||||||
`fileIDs` (newest first).
|
`fileIDs` (newest first).
|
||||||
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
|
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
|
||||||
@@ -811,6 +839,7 @@ from a very old client, is stored unchecked.
|
|||||||
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
|
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
|
||||||
`LibraryChange`
|
`LibraryChange`
|
||||||
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
|
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
|
||||||
|
- `src/exif.ts`: `PhotoExif`
|
||||||
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
|
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
|
||||||
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
|
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
|
||||||
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
|
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ declares one.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
||||||
manager squash-merges a reviewed pull request into `next`, and that only sneak
|
manager squash-merges a reviewed pull request into `next`, and that only sneak
|
||||||
|
|||||||
+145
@@ -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<typeof exifReader>;
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -84,6 +84,7 @@ export type {
|
|||||||
LibrarySnapshot,
|
LibrarySnapshot,
|
||||||
LibraryChange,
|
LibraryChange,
|
||||||
} from "./library/records.js";
|
} from "./library/records.js";
|
||||||
|
export type { PhotoExif } from "./exif.js";
|
||||||
export { decryptCollection, decryptFile } from "./model/index.js";
|
export { decryptCollection, decryptFile } from "./model/index.js";
|
||||||
export { downloadFile, downloadThumbnail } from "./download/index.js";
|
export { downloadFile, downloadThumbnail } from "./download/index.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export interface ContentOptions {
|
|||||||
export interface PhotoContent {
|
export interface PhotoContent {
|
||||||
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
||||||
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
||||||
|
savePath(fileID: number): string | undefined;
|
||||||
|
isLocal(fileID: number): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnsureResult {
|
export interface EnsureResult {
|
||||||
@@ -435,6 +437,29 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
|
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
|
// Get an original for a backup. One not present anywhere is written
|
||||||
// straight to `destination` and recorded there, so no second copy lands
|
// straight to `destination` and recorded there, so no second copy lands
|
||||||
// in the cache; one already present is returned where it is.
|
// in the cache; one already present is returned where it is.
|
||||||
|
|||||||
+56
-10
@@ -11,11 +11,15 @@
|
|||||||
// access and, for an album, its photos. They are not sent across IPC — the
|
// access and, for an album, its photos. They are not sent across IPC — the
|
||||||
// plain records are the serializable surface, and `record()` returns one.
|
// plain records are the serializable surface, and `record()` returns one.
|
||||||
//
|
//
|
||||||
// A `Photo` also fetches its own bytes: `original()` and `thumbnail()` go
|
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
|
||||||
// through the on-disk content cache (issue #46), the one place in this module
|
// `content()` and `exif()` go through the on-disk content cache (issue #46),
|
||||||
// that is not synchronous and RAM-only. A library opened without a content
|
// and are the one place in this module that may touch the network. A library
|
||||||
// source leaves that cache absent, and those two methods then throw.
|
// 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 { CollectionType, FileType } from "../model/types.js";
|
||||||
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
||||||
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
||||||
@@ -35,7 +39,7 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
|||||||
export class Photo {
|
export class Photo {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly rec: PhotoRecord,
|
private readonly rec: PhotoRecord,
|
||||||
private readonly content?: PhotoContent,
|
private readonly cache?: PhotoContent,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get fileID(): number {
|
get fileID(): number {
|
||||||
@@ -50,6 +54,13 @@ export class Photo {
|
|||||||
get takenAt(): number {
|
get takenAt(): number {
|
||||||
return this.rec.takenAt;
|
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 {
|
get fileType(): FileType {
|
||||||
return this.rec.fileType;
|
return this.rec.fileType;
|
||||||
}
|
}
|
||||||
@@ -68,6 +79,12 @@ export class Photo {
|
|||||||
get longitude(): number | undefined {
|
get longitude(): number | undefined {
|
||||||
return this.rec.longitude;
|
return this.rec.longitude;
|
||||||
}
|
}
|
||||||
|
get hash(): string | undefined {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -75,6 +92,20 @@ export class Photo {
|
|||||||
return this.rec.isHidden;
|
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 {
|
record(): PhotoRecord {
|
||||||
return this.rec;
|
return this.rec;
|
||||||
}
|
}
|
||||||
@@ -84,21 +115,36 @@ export class Photo {
|
|||||||
// `videoPath`. Served from the cache (or the backup download directory)
|
// `videoPath`. Served from the cache (or the backup download directory)
|
||||||
// when already present, otherwise fetched through the content pool.
|
// when already present, otherwise fetched through the content pool.
|
||||||
async original(opts?: ContentOptions): Promise<ContentResult> {
|
async original(opts?: ContentOptions): Promise<ContentResult> {
|
||||||
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.
|
// As `original`, for the thumbnail, through the thumbnail pool.
|
||||||
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
||||||
return this.contentOrThrow().thumbnail(this.rec.fileID, opts);
|
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private contentOrThrow(): PhotoContent {
|
// The original's bytes, read from where `original()` puts it. For a live
|
||||||
if (!this.content) {
|
// photo, its image's.
|
||||||
|
async content(opts?: ContentOptions): Promise<Uint8Array> {
|
||||||
|
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<PhotoExif> {
|
||||||
|
if (this.rec.fileType === "video") return {};
|
||||||
|
return readPhotoExif(await this.content(opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheOrThrow(): PhotoContent {
|
||||||
|
if (!this.cache) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Photo content requires a library opened with a content cache",
|
"Photo content requires a library opened with a content cache",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.content;
|
return this.cache;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -5,10 +5,11 @@
|
|||||||
// owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in
|
// owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in
|
||||||
// the main process; the window only ever sees these records.
|
// the main process; the window only ever sees these records.
|
||||||
//
|
//
|
||||||
// Ente holds edited/basic times in microseconds; records expose `takenAt` in
|
// Ente holds edited/basic times in microseconds; records expose `takenAt` and
|
||||||
// milliseconds. The magic-metadata field names below are the ones the Ente
|
// `modifiedAt` in milliseconds. The magic-metadata field names below are the
|
||||||
// clients write, confirmed against the repo's own fixtures: `w`/`h` in
|
// ones the Ente clients write, confirmed against the repo's own fixtures:
|
||||||
// test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts.
|
// `w`/`h` in test/cli/metadata-backup.test.ts, `visibility` in
|
||||||
|
// test/library/store.test.ts.
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Collection,
|
Collection,
|
||||||
@@ -33,12 +34,19 @@ export interface PhotoRecord {
|
|||||||
// Milliseconds. `pubMagicMetadata.editedTime` when the user edited the
|
// Milliseconds. `pubMagicMetadata.editedTime` when the user edited the
|
||||||
// date, else basic-metadata `creationTime`.
|
// date, else basic-metadata `creationTime`.
|
||||||
takenAt: number;
|
takenAt: number;
|
||||||
|
// Milliseconds. Basic-metadata `modificationTime`.
|
||||||
|
modifiedAt: number;
|
||||||
fileType: FileType;
|
fileType: FileType;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: 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;
|
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.
|
||||||
@@ -125,6 +133,7 @@ const toPhotoRecord = (
|
|||||||
albumIDs,
|
albumIDs,
|
||||||
title: asString(pub.editedName) ?? rep.metadata.title,
|
title: asString(pub.editedName) ?? rep.metadata.title,
|
||||||
takenAt: microsToMillis(takenAtMicros),
|
takenAt: microsToMillis(takenAtMicros),
|
||||||
|
modifiedAt: microsToMillis(rep.metadata.modificationTime),
|
||||||
fileType: rep.metadata.fileType,
|
fileType: rep.metadata.fileType,
|
||||||
isArchived: visibility === VISIBILITY_ARCHIVED,
|
isArchived: visibility === VISIBILITY_ARCHIVED,
|
||||||
isHidden: visibility === VISIBILITY_HIDDEN,
|
isHidden: visibility === VISIBILITY_HIDDEN,
|
||||||
@@ -140,6 +149,8 @@ const toPhotoRecord = (
|
|||||||
record.latitude = rep.metadata.latitude;
|
record.latitude = rep.metadata.latitude;
|
||||||
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.file.size !== undefined) record.fileSize = rep.file.size;
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-54
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|||||||
import * as jpeg from "jpeg-js";
|
import * as jpeg from "jpeg-js";
|
||||||
import exifReader from "exif-reader";
|
import exifReader from "exif-reader";
|
||||||
import type { Client } from "./client.js";
|
import type { Client } from "./client.js";
|
||||||
|
import { extractExifFromJpeg } from "./exif.js";
|
||||||
import type { Library, Photo } from "./library/index.js";
|
import type { Library, Photo } from "./library/index.js";
|
||||||
import { sanitizeFileName } from "./filename.js";
|
import { sanitizeFileName } from "./filename.js";
|
||||||
import {
|
import {
|
||||||
@@ -19,60 +20,6 @@ export interface MetadataBackupOptions {
|
|||||||
onProgress?: ProgressCallback;
|
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
|
// 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
|
// is malformed or cannot be parsed, the record carries the reason in
|
||||||
// `exifError`.
|
// `exifError`.
|
||||||
|
|||||||
@@ -8,10 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { extractExifFromJpeg } from "../../src/exif.js";
|
||||||
extractExifFromJpeg,
|
import { extractImageMetadata } from "../../src/metadata-backup.js";
|
||||||
extractImageMetadata,
|
|
||||||
} from "../../src/metadata-backup.js";
|
|
||||||
|
|
||||||
const SOI = [0xff, 0xd8]; // start of image
|
const SOI = [0xff, 0xd8]; // start of image
|
||||||
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
|
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and
|
* `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
|
* a cached path shows up on the projected record. A library opened without a
|
||||||
* content source leaves those methods throwing rather than silently doing
|
* 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";
|
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 { ContentSource } from "../../src/library/content.js";
|
||||||
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.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;
|
const USER_ID = 7;
|
||||||
|
|
||||||
@@ -70,14 +71,23 @@ class MockClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A content source that writes a marker file and counts thumbnail fetches.
|
// A content source that writes `original` as every original and a marker file
|
||||||
const stubSource = (): ContentSource & { thumbCalls: () => number } => {
|
// 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;
|
let thumbCalls = 0;
|
||||||
return {
|
return {
|
||||||
|
originalCalls: () => originalCalls,
|
||||||
thumbCalls: () => thumbCalls,
|
thumbCalls: () => thumbCalls,
|
||||||
original: async ({ destination }) => {
|
original: async ({ destination }) => {
|
||||||
writeFileSync(destination, "orig-bytes");
|
originalCalls++;
|
||||||
return { bytesWritten: 10 };
|
writeFileSync(destination, original);
|
||||||
|
return { bytesWritten: original.length };
|
||||||
},
|
},
|
||||||
thumbnail: async ({ destination }) => {
|
thumbnail: async ({ destination }) => {
|
||||||
thumbCalls++;
|
thumbCalls++;
|
||||||
@@ -211,3 +221,202 @@ describe("Library content wiring", () => {
|
|||||||
await lib.close();
|
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<FilesPage> {
|
||||||
|
return { files: this.files, deleted: [], cursor: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = (opts: Partial<LibraryOptions> = {}): Promise<Library> =>
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -209,6 +209,31 @@ 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", () => {
|
||||||
|
// 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", () => {
|
it("byID returns undefined for an unknown file id", () => {
|
||||||
const records = deriveRecords([collection(1)], [file(1, 1)]);
|
const records = deriveRecords([collection(1)], [file(1, 1)]);
|
||||||
expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined();
|
expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined();
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ describe("deriveRecords: photo mapping", () => {
|
|||||||
expect("width" in rec).toBe(false);
|
expect("width" in rec).toBe(false);
|
||||||
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("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