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

11 KiB
Raw Blame History

Immich research note

Immich 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
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
  • 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)
  • 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
  • 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.