Files
lightcraft/plan/immich.md
T
Andrew FrahnandClaude Opus 5.5 6cbc0eb5a9 Phase 0: configurable brand, MIT licence, plan/ and Immich tracker
- brand.toml is the only place the product name lives; every crate and app
  reads it through dac-brand (env vars with legacy fallback, settings folder
  with copy-only migration, default library, preset extensions, i18n {app})
- cargo xtask brand check|test, run|install|package, docs, rename-crates,
  deny and immich; packaging files are templates rendered from brand.toml
- icons regenerated from brand/icon.svg; upstream branding removed
- MIT licence (segment and fetch stay Apache-2.0); cargo deny licence policy
- clean-room rules in AGENTS.md/CLAUDE.md; docs/upstream-merge.md
- plan/ rebuilt and committed; parity tracker reopens Classic rows, adds
  PLAN.md section 2 rows and the IMM Immich section; plan/immich.md and a
  pinned Immich test server

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:30:06 +11:00

147 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Immich research note
[Immich](https://immich.app) is a self-hosted photo and video server. The app integrates with it (tracker section
"IMM. Immich integration", PLAN.md §2.14) so the desktop catalog and an Immich server act as one library.
**Checked against:** Immich **v3.3.1** (released 2026-10-08, the latest release on 2026-10-10), using the OpenAPI
document published with that tag (`open-api/immich-openapi-specs.json`, `info.version` 3.3.1) and a live v3.3.1
server started by `cargo xtask immich up`. Facts marked *(verified)* were confirmed against that server.
## Licence note
- The Immich server, web app and mobile apps are **AGPL-3.0**.
- We only talk to a server over HTTP. Our client is written from the public API documentation and the OpenAPI
description; we do **not** vendor Immich code, its generated TypeScript/Dart SDKs, or code generated from its
OpenAPI file. Hand-written request/response structs for the endpoints we use are our own work.
- The test server in `xtask/immich/compose.yml` runs Immich's published container images unmodified; nothing from
them is distributed with the app.
## Versions
- **Minimum supported: v3.0.0.** The v3 line is what we test. Endpoints we rely on were all marked stable in v2,
but behaviour we depend on (rating −1 = rejected, the v3 deprecations below) is checked only on v3. Older servers
get a clear "please upgrade" message from IMM-CONNECT rather than partial behaviour.
- **Version check:** `GET /api/server/version` → `{major, minor, patch}` (no auth needed); `GET /api/server/ping` →
`{"res":"pong"}` for reachability. Store the version per server and gate features on it.
- **API history metadata:** every operation in the OpenAPI file carries `x-immich-history` (added / beta / stable /
deprecated per version) and `x-immich-permission`. Use them when moving to a new release: diff the operations we
call and fail the integration tests on a removed one.
- **Deprecations to watch (v3):** `PUT` updates of single objects (`PUT /assets/{id}`, `PUT /assets`,
`PUT /tags/{id}`, `PUT /people/{id}`, `PUT /stacks/{id}`, `PUT /libraries/{id}`,
`PUT /api-keys/{id}`) are marked deprecated with a replacement of the same operation id; they still work in
v3.3.1 *(verified for `PUT /assets/{id}`)*. Wrap each update behind one function so the switch is local.
`GET /server/config` and `/server/features` are deprecated in favour of a public-config endpoint (v3.2.0).
- **Moving to a new release:** update the four images in `xtask/immich/compose.yml` from that release's own compose
file, re-run the integration tests, then update "Checked against" here.
## Authentication, API keys and permissions
- **API key** (what the app stores): header `x-api-key: <secret>`. Created by a logged-in user with
`POST /api/api-keys {name, permissions[]}`; the response carries `secret` once *(verified)*. Keys are per user.
- **Permissions** are fine-grained strings (`asset.read`, `asset.upload`, `asset.update`, `asset.download`,
`album.create`, `albumAsset.create`, `tag.asset`, `person.read`, `sharedLink.create`, `library.read`,
`stack.create`, …) or `all`. IMM-CONNECT should ask for the minimal set per feature and explain which features a
key with fewer permissions disables. `GET /api/api-keys/me` reads the current key's permissions.
- **Session login** (test tooling only): `POST /api/auth/login {email, password}` → `accessToken`, used as
`Authorization: Bearer`. The first user is created with `POST /api/auth/admin-sign-up {email, password, name}`,
which is refused once an admin exists *(verified)*.
- The key is a secret: stored in the OS keychain (`credentials` crate), never in the catalog, logs or XMP.
## Concepts
| Concept | What it is | How we map it |
|---|---|---|
| **Asset** | one photo or video owned by one user (`id` UUID, `type` IMAGE/VIDEO, `originalFileName`, `originalPath`, `fileCreatedAt`, `localDateTime`, `exifInfo`, `isFavorite`, `visibility`, `isTrashed`, `libraryId`, `stack`, `people`, `tags`) | a catalog photo, linked through the `remote_identity` table (server, asset id, checksum) |
| **Checksum** | **SHA-1 of the original file, base64 encoded** in `AssetResponseDto.checksum` *(verified: equals `openssl sha1 -binary | base64`)*; inputs to the duplicate check also accept hex | computed at import for every original (Phase 1.7); the link key for IMM-LINK |
| **Duplicate detection** | an upload whose checksum matches an existing asset of the same user is not stored again: `POST /assets` answers `status: "duplicate"` with the existing id *(verified)*; `POST /assets/bulk-upload-check` answers `action: reject, reason: duplicate, assetId` per item without uploading *(verified)* | link before upload; never upload twice |
| **Visibility** | `timeline` (normal), `archive` (hidden from the timeline), `hidden` (e.g. the video half of a live photo), `locked` (locked folder, needs a PIN session) | archive ↔ an "archived" flag in sync; never touch `locked` assets |
| **Album** | a named, ordered set of assets, possibly shared with other users | collection ↔ album (IMM-PUBLISH, IMM-SYNC) |
| **Tag** | hierarchical tags (`parent/child` values), assigned to assets | keyword hierarchy ↔ tags |
| **Person / face** | people with names and face boxes per asset (`/people`, `/faces`), found by Immich's ML service | IMM-PEOPLE: import names and regions; push names back |
| **Stack** | a group of assets with a primary (`POST /stacks {assetIds}`, first id = primary) | our stacks; IMM-PUBLISH stacks a render with its original |
| **External library** | an admin-defined set of server-side folders (`importPaths`, `exclusionPatterns`) that Immich scans and indexes **in place** (read-only originals; `isOffline` when a file disappears) | IMM-EXTLIB: the catalog and Immich index the same folders; link by checksum or by path mapping, never upload |
| **Shared link** | a public URL for an album (`type: ALBUM`) or for chosen assets (`INDIVIDUAL`), with optional expiry, password, download/upload permission, metadata visibility and custom slug | IMM-SHARELINK |
| **Trash** | deleted assets go to trash first (`isTrashed`), restorable via `/trash/restore/assets` | IMM-PUBLISH removes only by trashing, never force-deletes |
| **Smart search** | CLIP embedding search computed by the ML service | IMM-SEARCH |
## Ratings and favourites
- **Rating** lives in the asset's EXIF info (`exifInfo.rating`) and is set with `UpdateAssetDto.rating`.
Valid values: **1–5** (stars), **−1** (rejected), or **null** (unrated). **0 is refused** with a validation error
*(verified on v3.3.1)*. Mapping: our 0 stars ↔ null; our reject flag ↔ −1 *only when the photo has no star
rating* (one field cannot hold both, so the conflict rule in IMM-SYNC is: a reject wins on push, and a −1 from
Immich sets reject and clears stars).
- Immich reads the rating from the file's metadata (XMP/EXIF) on upload; the web UI shows ratings only when the
user enables them in their preferences, but the API accepts them regardless.
- **Favourite** (`isFavorite`) is a separate per-asset boolean, independent of rating. Mapping: favourite ↔ **pick
flag** (configurable: pick or a chosen colour label), as in PLAN.md §2.14.
- **Description** (`UpdateAssetDto.description`, shown in `exifInfo.description`) ↔ our caption. Location
(`latitude`, `longitude`) and capture time (`dateTimeOriginal`) are writable the same way.
- Immich has no colour labels and no title field separate from description; those stay local (or go to tags if the
user opts in).
## Endpoints we need
All paths are under `/api`. Permission in brackets.
### Connect (IMM-CONNECT)
- `GET /server/ping`, `GET /server/version`, `GET /server/about` [server.about], `GET /server/media-types`
- `GET /users/me` [user.read], `GET /api-keys/me`
### Link and import (IMM-LINK, IMM-IMPORT)
- `POST /assets/bulk-upload-check` [asset.upload]: checksum → existing asset id, without uploading
- `POST /search/metadata` [asset.read]: filter by `checksum`, `originalPath`, `libraryId`, `albumIds`, `personIds`,
`tagIds`, `isFavorite`, `rating`, dates, `visibility`; paged with `page` / `size`
- `GET /assets/{id}` [asset.read], `GET /assets/{id}/original` [asset.download], `GET /assets/{id}/thumbnail`
[asset.view]
- `GET /albums` / `GET /albums/{id}` [album.read]
- Timeline browsing uses `/timeline/buckets` and `/timeline/bucket`, but they are marked **internal** (v1); prefer
`POST /search/metadata` with date ranges and only fall back to the buckets for speed.
### External library (IMM-EXTLIB)
- `GET /libraries`, `GET /libraries/{id}` [library.read], `POST /libraries` [library.create, admin],
`POST /libraries/{id}/scan` [library.update], `POST /libraries/{id}/validate`
### Publish (IMM-PUBLISH)
- `POST /assets` [asset.upload] multipart: `assetData`, `fileCreatedAt`, `fileModifiedAt` (required), `filename`,
`isFavorite`, `visibility`, optional `sidecarData`; header `x-immich-checksum` (SHA-1) lets the server reject a
duplicate before reading the body *(upload verified)*
- `POST /albums` [album.create], `PUT /albums/{id}/assets` and `PUT /albums/assets` [albumAsset.create],
`DELETE /albums/{id}/assets` [albumAsset.delete], `PATCH /albums/{id}` [album.update]
- `POST /stacks` [stack.create], `DELETE /stacks/{id}/assets/{assetId}` [stack.update]
- `DELETE /assets` [asset.delete] (to trash), `POST /trash/restore/assets`
### Sync (IMM-SYNC)
- `PUT /assets/{id}` / `PUT /assets` [asset.update]: `rating`, `isFavorite`, `description`, `latitude`,
`longitude`, `dateTimeOriginal`, `visibility` (deprecated method, see Versions)
- `GET /tags`, `PUT /tags` (upsert by value) [tag.create], `PUT /tags/assets` (bulk) / `PUT /tags/{id}/assets` /
`DELETE /tags/{id}/assets` [tag.asset]
- Change feed: `POST /sync/stream` [sync.stream] with `POST /sync/ack` checkpoints is what Immich's own mobile app
uses for incremental sync. Prefer it over polling once evaluated; until then poll with `updatedAfter` in
`POST /search/metadata`.
### People (IMM-PEOPLE)
- `GET /people`, `GET /people/{id}`, `GET /people/{id}/thumbnail` [person.read], `GET /faces?id=<assetId>`
[face.read], `PUT /people` (bulk rename) [person.update], `POST /people/merge` (v3.2.1)
### Search (IMM-SEARCH)
- `POST /search/smart` [asset.read]: `query` (natural language), or `queryAssetId` (find similar), plus the same
filters as metadata search; `GET /search/suggestions`, `GET /search/explore`, `GET /search/places`
### Shared links (IMM-SHARELINK)
- `POST /shared-links` [sharedLink.create]: `type` ALBUM / INDIVIDUAL, `albumId` or `assetIds`, `expiresAt`,
`password`, `allowDownload`, `allowUpload`, `showMetadata`, `description`, `slug`;
`GET /shared-links`, `PATCH /shared-links/{id}`, `DELETE /shared-links/{id}`
## Test server
`cargo xtask immich up | seed | down [--volumes]` (`xtask/src/immich.rs`):
- `xtask/immich/compose.yml` pins `immich-server` and `immich-machine-learning` to `v3.3.1`, and the Valkey 9 and
`immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0` images by digest, exactly as that release's compose
file does. Data lives in docker volumes; the server listens on `127.0.0.1:2284` (not the default 2283).
- `seed` creates `admin@example.invalid`, logs in, creates an API key with `all` permissions (written to
`target/immich/api-key`, mode 0600), uploads 8 small PNGs generated by the xtask (our own work, CC0) and puts them
in the album "xtask fixtures". It is idempotent: later runs see the uploads as duplicates.
- `IMMICH_URL` points the seed at another test server.