Responsive web app to index, cull and export the raw photos/videos from the NAS, across multiple devices (Sony A7C II, DJI drone, iPhone, Ray-Ban Meta).
Guiding principle: the filesystem and the RAWs are touched only once (indexing + derivative generation). Everything else — browsing, culling, queries — goes through Postgres and a derivative cache. Culling always happens on lightweight proxies, never on the RAWs.
This repository implements the MVP (see §11 of the specs): index + RAW preview extraction + database + "ignored folder" marking + culling grid (pick/ reject/stars) + "RAW copy for Capture One" export. Photos only.
Decoupled components, communicating through Postgres + a Redis queue (BullMQ):
NAS (HDD, RAW/video, RO) ──► Indexer ──► Postgres (sessions, assets, ratings)
│
└─enqueue──► Derivative workers ──► Storage (disk/MinIO)
(exiftool + sharp) thumbs + proxies
Next.js (cull UI + API) ◄── Postgres + Storage
└─► Export worker ──┬─► RAW copy for Capture One (+ source→export lineage)
└─► Push to Immich (REST API) (+ same lineage)
Everything (Postgres, Redis, derivatives, exports, inbox) lives on the
Optiplex. The NAS sessions that are already filed are mounted read-only;
only the incoming zone (where imports land) is mounted read/write.
Winnow now carries its own login and multi-user accounts (the old upstream-only Traefik basic-auth is no longer required — the layers stack fine if you keep it). One shared library, per-user roles:
| Role | Can |
|---|---|
viewer |
browse everything (grid, viewer, search, downloads) — change nothing |
editor |
viewer + cull (picks/stars/tags/trash), geotag, import/upload, export |
admin |
editor + volumes, settings, scan control, pipeline, purge, user management |
How it works:
- First run: with no account in the database,
/loginbecomes a one-time "create the administrator" form (/api/auth/setup), which locks itself as soon as one user exists. Admins then invite the others from Users (rail → account chip → Users). - Invites — passwords never travel: an admin creates an account without
a password and gets a one-time link (
/invite/<token>, single-use, 7-day expiry) to hand over on a channel they trust. Opening it lets the person choose their own password — the admin never knows nor types it, and a link that leaks after use is worthless. Lost password? Same mechanism: Reset link issues a fresh invite; accepting it sets the new password and revokes every old session. Only the SHA-256 of invite tokens is stored; re-issuing replaces the pending link, and a pending link can be revoked. - Sessions: local accounts (scrypt-hashed passwords), a 30-day sliding
cookie session (
httpOnly,SameSite=Lax,Securebehind https). Postgres stores only the SHA-256 of the session token. Logout, password changes and account disabling revoke sessions server-side. Everyone can change their own password from the account chip (current password re-proved; other sessions revoked). - Enforcement is central (
src/proxy.ts+src/lib/authz.ts): every page and API request is validated against the session (or, on the API only, an app token — below); viewers are read-only, mutations neededitor, infrastructure verbs (/api/roots,/api/settings,/api/scan,/api/pipeline,/api/purge, …) needadmin. Only/login,/invite/<token>(the token is the credential), the auth handshake and/api/health(Docker healthcheck) stay public. - Attribution: ratings and export jobs record which account made them
(
ratings.rated_by,export_jobs.created_by). - App tokens, for a client app that cannot hold the session cookie —
Atelier launched from a phone's home screen runs in a cookie jar of its own.
An admin mints one on Users › App tokens: it acts as one chosen account
(same trips, same ratings byline), capped to Read only or Read & write,
never admin, the API only, with an optional expiry. The app sends
Authorization: Bearer wnw_…; on the few media URLs an<img>/<video>loads (thumb, proxy, download, sidecar) it may ride as?access_token=instead — which puts it in the reverse proxy's access log, the price of an element not being able to send a header. Shown once, stored as a SHA-256, revocable one by one; disabling the account kills its tokens, a password change does not.GET /api/capabilities(auth.token) states the contract. A token may be minted for an agent (Used by › An agent): same caps, and every rating it writes is stampedratings.rated_via = 'agent'— see Driving Winnow from Claude.
The network posture is unchanged: Traefik + Cloudflare Tunnel expose
the app behind a domain; do not publish ports 3000/5432/6379 directly on
the Internet — only Traefik routes to the app. (For off-LAN mobile access,
uploads go through the tunnel.)
| # | Decision | Choice |
|---|---|---|
| 1 | Derivatives: MinIO or disk | Disk cache, behind an S3-style interface (src/lib/storage) → switch to MinIO via STORAGE_DRIVER=s3 without touching the code. |
| 2 | Mount vs NAS agent | RO mount for the MVP (decided by the specs). |
| 3 | Hash-based deduplication | Yes from the MVP: partial content_hash (size + endpoints) + unique index. A suspected duplicate is verified by full-content compare before being dropped — a false partial-hash collision is indexed anyway (never lost), and every hit is logged in duplicate_hits for audit. |
| 4 | Linking key for finals → source | Implemented, tool-agnostic: basename + capture time (never Capture One specifics). See Finals → sources. |
Winnow grew by verbs — sift, search, people, gear, timeline — and each one took a slot in the navigation rail. Not all of them are wanted every day, and not all of them are finished. Settings › Features is one switch per optional section:
| Section | Default | Why it is optional |
|---|---|---|
| Library | always on | Browsing, culling and exporting the library is what Winnow is. It has no flag. |
| Timeline | off | The chapters are re-derived on every request and the cut rules are still being reworked; it ships off until it settles. |
| Heatmap | off | Nothing unfinished — it is a step-back view of the whole library rather than part of the daily cull, so it ships off until you want the rail slot. |
| Sift | on | The swipe deck. |
| Search | on | Keyword search over captions, OCR text and tags. |
| People | on | The face-clustering shelf. |
| Gear | on | The bodies-and-lenses shelf. |
A section that is off is not merely hidden. Its pages answer 404, and so
do the API routes it owns — so a bookmark, a typed URL or a client app cannot
reach a feature this instance has turned off. GET /api/capabilities states
media.timeline for exactly that reason: Atelier
reads it and says "this instance does not serve a timeline" rather than
guessing from a 404.
Routes the rest of the app also reads stay open on purpose — hiding the Gear
shelf must not empty the gallery's lens filter, and GET /api/people remains
the directory the gallery's person facet falls back on. Each gated route says
at its top why it belongs to its section alone.
The flags live in Postgres (app_settings, one features row), not in the
environment: NEXT_PUBLIC_* is inlined at next build and the image is built
in CI, so an env flag could never be flipped on the Optiplex. Flipping a switch
is admin-only and takes effect immediately — the rail redraws under your hand.
cp .env.dist .env
# Edit NAS_MOUNT (RO sessions) and NAS_INCOMING (RW imports), adjust the paths.
docker compose up -d --build
# `migrate` applies the schema, then app (http://localhost:3000) + worker start.Then, from the Volumes page, + Add folder — enter a NAS path (as seen
inside the container, e.g. /nas/2026/…) and pick its type
(Incoming / Final / Export). See Volumes.
Requires a reachable Postgres and Redis, plus perl (for exiftool) and the
sharp libs (provided by the prebuilt binaries).
npm install
cp .env.dist .env # adapt DATABASE_URL / REDIS_URL (localhost)
npm run migrate
npm run dev # UI + API on http://localhost:3000
npm run worker # in another terminal: BullMQ workers
# Index a folder directly (without Redis):
npm run scan -- /path/to/folder --syncTo run the whole stack on your machine without pointing at the remote NAS,
use the docker-compose.dev.yml override. It bind-mounts the source (live
edits, no rebuild), runs next dev / tsx watch, and maps the three "NAS"
mounts to local folders (./nas, ./nas-incoming, ./nas-final) whatever
NAS_* says in .env. Postgres, Redis and all other state stay in local Docker
volumes — nothing remote is involved.
cp .env.dist .env # keep DATABASE_URL / REDIS_URL as-is (compose service names)
# First run builds the image (system deps + npm ci); afterwards code changes
# hot-reload and only package.json changes need another --build.
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --buildDrop a few sample RAW/JPEG/MP4 into ./nas, then in the UI (http://localhost:3000)
go to Volumes → + Add folder and enter the path as seen inside the
container (e.g. /nas/2026/…). ./nas, ./nas-incoming, ./nas-final and
./data are git-ignored (Docker auto-creates them on first up).
To work on your real library from this computer, point the dev stack at
the NAS as this machine mounts it (still read-only for the sessions) with
DEV_NAS_MOUNT, DEV_NAS_INCOMING and DEV_NAS_FINAL in .env (e.g.
DEV_NAS_MOUNT=/Volumes/photo). It is a library of its own — its own Postgres,
indexed from scratch — beside the server's, never a copy of it.
With Atelier on the same machine. The dev stack allows Atelier's own dev
server (http://localhost:5173) by itself: run Atelier with npm run dev,
connect http://localhost:3000 on its #/sources — same site, so this
instance's sign-in works — and the whole video workflow stays on your computer:
pick from this Winnow, edit and export in Atelier, then send the export to a
Winnow to archive it (POST /api/upload, imported into Incoming). To send it to
the server's instance instead, add http://localhost:5173 to that
instance's CORS_ALLOWED_ORIGINS and connect it in Atelier with an app token
minted on its Users › App tokens: localhost and the server are different
sites, so its cookie cannot reach the local page.
The standalone (non-dev)
docker compose upreadsNAS_MOUNT/NAS_INCOMING/NAS_FINALfrom.env. To point that at local dirs instead of the NAS, set those three variables to local paths (or remove them to fall back to the./nas*defaults baked intodocker-compose.yml).
See .env.dist. Main ones:
DATABASE_URL,REDIS_URLSTORAGE_DRIVER=disk|s3,STORAGE_DISK_PATH, and theS3_*for MinIOEXPORT_DIR: folder where the "RAW copy" export drops the originalsIMMICH_*: push a culled session's keepers to Immich —IMMICH_ENABLED(off by default),IMMICH_BASE_URL+IMMICH_API_KEY,IMMICH_ALBUM_MODE. See Push to Immich*_CONCURRENCY: bounded concurrency to spare the NAS's full HDDTHUMB_SIZE/PROXY_SIZE/ qualitiesGEOCODE_*: reverse geocoding (GPS → place names) —GEOCODE_BASE_URL(Nominatim endpoint, self-hostable),GEOCODE_USER_AGENT(required by Nominatim's policy),GEOCODE_ENABLED. See PlacesML_*: ML analysis (faces + text-in-image) —ML_ENABLED(off by default),ML_BASE_URL(your immich-machine-learning container), the per-task model/threshold knobs. See Faces & text
The whole environment is validated once at startup by a Zod schema in
src/lib/config.ts. A missing/garbled/incoherent variable (a typo'd
STORAGE_DRIVER, a non-numeric *_CONCURRENCY, STORAGE_DRIVER=s3 without
the S3_* credentials…) fail-fasts the process with a message listing every
offending variable — instead of silently degrading in production.
| Method & route | Role |
|---|---|
POST /api/index/scan { path } |
Registers the root and enqueues an indexing run |
GET /api/stats |
Counters (media / scan / analyzed / pending) + queue activity + pause + rates |
GET /api/capabilities |
What this instance can do, for a client app (Atelier): API version, auth methods + whether CORS is on, media facts (sidecars, Range on derivatives / not on originals, proxy codec & size, the partial content_hash), the document bucket (kinds + body cap), the unbuilt reminders flag, storage driver, and the caller's role. Facts about the code as it is — a client decides from these rather than guessing |
GET /api/apps/:app/docs ?kind=trip|project|roll|presets|lutpack|atlas |
A client app's own documents (Atelier's road trips, later its projects): opaque JSON Winnow never reads, scoped to the signed-in user — any role, since a viewer owns its trips as it owns its password. Bodies inlined, newest first; with &fields=summary each row carries the small summary its last PUT sent in place of doc (a row with none still carries its body) |
GET · PUT · DELETE /api/apps/:app/docs/:id |
One document, guarded by a revision: GET answers with an ETag (If-None-Match → 304); PUT { kind, version, doc } (application/json required, an optional summary object ≤ 16 KiB that the list hands back in place of the body, 96 MiB cap → 413 — the tunnel's ceiling, not a policy) needs If-Match on an existing row — stale or missing → 412 { etag, updated_at } so the client decides, never a merge; DELETE with If-Match → 204. A row that is not the caller's answers 404, never 403 |
GET /api/apps/:app/files |
The blobs this user keeps for the app: ids, sizes, what is used and what the caps are. Never bytes — it is what turns "push my whole pack" into "push the three you are missing" |
GET · PUT · DELETE /api/apps/:app/files/:sha256 |
One blob, content-addressed: the id IS the SHA-256 of the bytes and the route refuses a body that does not hash to it (400). GET answers the bytes with that hash as ETag and Cache-Control: private, immutable (If-None-Match → 304); PUT takes a raw body (16 MiB cap → 413, 512 MiB per user and app → 507) and is a no-op when the hash is already here; DELETE → 204. Not the caller's → 404. For what a document cannot hold: Atelier's purchased LUT lattices, 1.5-2 MB apiece |
GET /api/settings · PATCH /api/settings { scanPerHour?, analyzePerHour?, geocodePerHour?, geocodePrecisionM?, rescanMinutes? } |
Hourly scan/analyze/geocode rates (0 = unlimited) + geocode cell precision (metres) + periodic re-scan interval (minutes, 0 = off) |
POST /api/scan/control { action: pause|resume } |
Suspends/resumes indexing + derivative generation |
GET /api/failures |
Everything that failed (scan / analyze / import) + the deduplication audit (each duplicate joined to its kept asset for thumbnail/compare) + the missing originals awaiting triage |
POST /api/failures/retry { kind, ids? } |
Retries failures of a given family (kind=missing re-stats the missing originals and restores whichever are back) |
POST /api/integrity { root_id? } |
Queues an integrity sweep: re-stats every live original (a source gone from disk funnels into the missing-files triage) and verifies the thumb/proxy objects still exist in storage (a wiped derivative is re-enqueued for generation). Scoped to one volume via root_id, whole library otherwise |
GET /api/failures/duplicates/file ?path= |
Streams a recorded duplicate's raw file (whitelisted to duplicate_hits) so an unindexed extra copy can be inspected locally |
POST /api/failures/duplicates/delete { paths[] } |
Hard-deletes extra copies recorded in duplicate_hits (whitelisted · never an indexed asset · confined to the browsable area) and clears their audit rows |
POST /api/failures/duplicates/keep { contentHash, keepPath } |
Collapses a group of byte-identical copies to the single survivor the user picked. Keeping an on-disk copy relinks the library asset onto it (id/rating/tags/derivatives preserved) and deletes the former original; keeping the indexed copy just removes the recorded extras. False collisions are never eligible |
GET /api/pipeline/queue ?name=scan|analyze |
Live jobs of the scan/analyze queue, enriched with the root/asset they point at |
POST /api/pipeline/queue/remove { name, jobId } |
Removes one job from the scan/analyze queue (active jobs can't be removed mid-flight) |
GET /api/assets ?<filters>&cursor&sort=recent |
Paginated global gallery (cumulative filters incl. derivative_status, q= free-text path search, and has_edit/is_edit for the finals↔sources before/after link; sort=recent orders by last update) |
GET /api/assets/geo ?<filters> |
GPS points ({id,lat,lon}) of the geotagged matches — feeds the map view |
GET /api/assets/calendar ?<filters>&from&to |
Per-day {date,count,cover_id} aggregates in the [from,to] window + the full filtered bounds (min/max capture date) — feeds the calendar view |
GET /api/assets/timeline ?<filters>&mode=place|time|hybrid&gran=auto|city|county|region |
The library read as a story: chapters (a stay in a place, across session folders) with count, span, places, devices, folders, cover, a spread sample of ids, the local-day offset and whether the place was inferred; plus the per-month spine and the granularity actually used — feeds the Timeline |
GET/POST /api/timeline/chapters, PATCH/DELETE /api/timeline/chapters/:id |
Named spans — the human corrections to the timeline's derivation: {starts_at, ends_at, name?, place_label?, place_lat?, place_lon?}. A span renames, merges and/or locates everything captured inside it; spans may not overlap. The location is the chapter's, never written onto media |
GET/POST /api/timeline/breaks, DELETE /api/timeline/breaks/:id |
Forced splits — {at}: media at or after at start a new chapter. Deleting one re-glues the halves |
GET /api/facets |
Values + counts to build the filters |
GET /api/gear |
The shelf: every camera body the library was shot with and, nested under each, the lenses used on it — media count, photo/video split and date span tallied separately for Incoming and the Gallery. Feeds the Gear page |
GET /api/sessions ?kind&sort=captured|touched|progress|count&sort_dir&progress=untouched|partial|incomplete|complete |
List of sessions + counters (ready/pending + picks/rejects/unrated) + the most recent verdict time. sort ranks by capture date, last-touched, triage completeness or live-media count (count); progress filters by how far each session has been triaged |
PATCH /api/sessions/:id { ignored } |
Marks the folder as handled (cascade, stops derivatives) |
DELETE /api/sessions/:id ?files=true |
Deletes the session (cascade: assets/ratings/picks) + its derivative cache. files=true also removes the originals from disk (incoming only, confined to the session folder) — to clear an orphaned import |
GET /api/sessions/:id/assets?cursor&verdict&… |
Paginated grid (cursor-based) |
GET /api/assets/:id |
Detail + EXIF |
GET /api/assets/:id/thumb | /proxy |
Serves the derivative (bytes, or signed redirect on S3) |
GET /api/assets/:id/exports |
Lineage (finals linked to this original) |
PATCH /api/assets/:id/rating { verdict, star, color } |
Cull state |
POST /api/ratings/bulk { ids[], verdict, star } |
Quick bulk culling |
POST /api/assets/delete { ids[] | filter, restore? } |
Soft delete / restore — the recycle bin (hides from the library, RAW untouched). filter deletes/restores in bulk, e.g. { verdict:"reject" } |
GET /api/trash |
Trash summary: reclaimable count/bytes, rejects still in the library, recent purges |
POST /api/purge { filter?, dryRun? } |
Reclaim space: physically removes the trashed originals + derivatives (queued job). dryRun returns { count, bytes } to free. Only ever touches soft-deleted assets |
POST /api/assets/regenerate { ids[] } |
Rebuilds the derivatives (thumb + proxy) of a selection — re-enqueues generation whatever the current status |
POST /api/assets/geocode { ids[], precise? } |
Reverse-geocodes a selection: resolves GPS → place names (country/région/département/city, + tourist POI when precise). Deduped by coordinate cell so nearby shots share one lookup. See Places |
POST /api/assets/ml { ids[] } |
(Re)runs the ML analysis — face detection + OCR + sharpness/perceptual-hash — for a selection (e.g. after a container/model upgrade). See Faces & text |
GET /api/assets/:id/similar ?limit&max_distance |
Visually closest media by perceptual-hash (Hamming) distance — the near-duplicates of a shot (feeds the viewer's Similar strip) |
POST /api/assets/skip { ids[] } |
Takes assets out of the analyze pipeline (derivative_status → skipped); honoured even by an already-queued job |
POST /api/tags/assign { ids[], add?, remove? } |
Add/remove tags (single via ids:[id], or bulk) |
POST /api/export { name, target, filter } |
Creates + enqueues an export (filter.ids exports a precise selection). target ∈ capture_one (copy to the export folder) · immich (push, 400 unless configured) |
GET /api/export/targets ?probe=1 |
Destinations this deployment offers (drives the modal's Destination picker); probe=1 also pings Immich and validates the key |
GET /api/export/:id |
Status + result |
POST /api/upload (multipart files) |
Upload from the phone → inbox → import |
POST /api/import/offload { path } |
Offload from a mounted card (source kept) |
POST /api/import/inbox |
Manual re-trigger of the inbox import |
GET /api/import/:id |
Status of an import batch |
GET /api/roots · POST /api/roots { path, type } |
Registered volumes (+ session/asset counts); type ∈ incoming·final·export, path-guarded |
PATCH /api/roots/:id { type?, watch?, reindex? } · DELETE /api/roots/:id |
Re-type / re-index / remove a volume (remove cascades to its index, files untouched) |
GET /api/fs ?path=<dir> |
Subdirectories of <dir> for the folder picker — confined to the browse roots (BROWSE_ROOTS + the volume dirs); omit path for the starting locations |
POST /api/reconcile { root_id? } |
Links edited finals back to their source original (before/after). Retroactive + idempotent; root_id scopes to one finals root, otherwise every finals root. Returns { considered, linked, ambiguous, unmatched }. See Finals → sources |
Cursor-based pagination on (captured_at, id) — never an OFFSET. The
front-end grid infinite-scroll-loads the thumbnails as they come.
Claude Code, Claude Desktop — or any MCP client — can read the library, look
at the pictures and cull, through a small bridge (src/scripts/mcp.ts). It is
a client of this API, run on the agent's machine: every command is an
ordinary request with an app token, so the token's ceiling, the role policy,
the feature flags and the routes' validation apply exactly as for Atelier or a
browser. It adds no endpoint to the instance. The instance serves the bridge
itself, bundled into one plain-JS file (/agent/winnow-mcp.mjs, Node ≥ 18, no
dependency) and as a Claude Desktop extension (/agent/winnow.mcpb), both
built from src/ at every npm run build by scripts/build-agent-bridge.ts —
no checkout of this repository is needed. /agent/ is the one path outside the
session check besides the PWA files: it holds this public code and no secret.
The steps below are also an in-app guide, Users › Agents: pick your app, mint the token from there, and it fills the setup in and offers prompts to try.
-
Mint a token for the agent — Users › App tokens › New token, Used by: An agent. Read only lets it browse and look; Read & write also lets it cull. Its ratings are marked as an agent's (
rated_via = 'agent',by: agentin its listings), so a pick it made can be told from yours. The modal that shows the token once also prints the two setups below, filled in. -
Claude Code — one line in a terminal:
mkdir -p ~/.winnow && curl -fsSL https://winnow.steeve.website/agent/winnow-mcp.mjs -o ~/.winnow/winnow-mcp.mjs \ && claude mcp add winnow --scope user \ -e WINNOW_HOST=https://winnow.steeve.website -e WINNOW_TOKEN=wnw_… \ -- node ~/.winnow/winnow-mcp.mjs
--scope userkeeps the token in your own Claude Code settings (~/.claude.json), never in a project's committed.mcp.json. -
Claude Desktop — download
https://winnow.steeve.website/agent/winnow.mcpband open it: Claude installs the extension and asks for the address and the token, which it keeps in the system keychain.
The bridge has its own number, v1, v2… (src/lib/commands/bridgeVersion.ts,
as Atelier's): it moves only when what is installed moves — the bridge and the
commands it bundles — so a new number is worth installing, and the extension's
manifest says N.0.0, which is what makes Claude Desktop offer Update
instead of Uninstall. winnow_status says which bridge runs and, from
/api/capabilities, whether the instance serves a newer one;
node ~/.winnow/winnow-mcp.mjs --version prints a file's number. To update,
re-run the curl (Claude Code) or download and open the extension again
(Claude Desktop). A change to the bridge without a new number fails
npm run build in CI and npm test, naming the number and hash to write.
WINNOW_HOST must be https:// (plain http only to localhost). From a
checkout the bridge also runs unbundled — node_modules/.bin/tsx src/scripts/mcp.ts — but never through npm run, whose banner would land on
stdout, the protocol channel. Several apps may run the bridge at once (Claude
Code and Claude Desktop side by side): each is its own process holding no
port, unlike Atelier's, which listens for its browser tab.
The server offers three tools — the same three Atelier's does:
winnow_status (who it acts as, its role, whether it may write),
winnow_commands (every command with its JSON Schema and, when it cannot run,
why) and winnow_run ({ command, params }). Commands today:
| Command | What it does (route behind it) |
|---|---|
app.status |
Account, role (capped), via, whether writes are allowed, whether the Timeline is on |
library.days { from, to, kind? } |
Capture days with counts and a cover, ≤ 366 days a call (/api/assets/calendar) |
library.chapters { from?, to?, mode? } |
The Timeline's chapters — only while that section is on (/api/assets/timeline) |
assets.list { day | from/to, verdict?, star_min?, type?, kind?, search?, burst?, folder?, tag?, people? | who?, together?, faces?, camera?, city?, fold?, order?, limit?, cursor?, trash? } |
Media with their culling, one line per pair/pile unless fold: pairs (/api/assets) |
library.folders { kind?, progress?, sort?, order?, limit? } |
Folders with their triage counts and status — what is still to sort (/api/sessions) |
library.facets { kind? } |
The values the filters take, with counts — cameras, places, people, tags… (/api/facets) |
people.list { named? | unnamed?, hidden?, offset?, limit? } |
The people face analysis grouped, with their media counts (/api/people) |
people.get { id, faces? } |
One person: counts per half, cover, faces (face, asset, score) (/api/people/:id) |
people.sheet { ids? | named? | unnamed?, offset?, limit? } |
Up to 24 cover faces as images labelled #id name — to see who a stack is (/api/faces/:id/thumb) |
people.suggestions { limit? } |
Stacks that are probably the same person, with names (/api/people/suggestions) |
assets.faces { id } |
Who is in one medium: face, person, name, score, box (/api/assets/:id/faces) |
people.name { id, name } · people.hide { id, hidden } |
Name a person ("" clears) or hide a stack (PATCH /api/people/:id) |
people.merge { from, into } · people.reassign { person, assets, to? } |
Fold one stack into another; move one person's faces in some photos elsewhere (POST /api/people/:id/merge, …/reassign) |
assets.search { text, source?, limit? } |
Media ranked by a plain-words description (CLIP, /api/search); says so when the index is off |
assets.similar { id, maxDistance?, limit? } |
Near-duplicates by perceptual hash (/api/assets/:id/similar) |
assets.get { id } |
The full row (/api/assets/:id) |
assets.look { id, detail? } |
The thumbnail (~400 px) or, with detail, the 2048 px proxy, as an image the agent sees (/thumb, /proxy) |
assets.lookMany { ids } |
Up to 12 thumbnails side by side, one image each — a burst's frames, to choose the keeper |
cull.set { id, verdict?, star?, color? } |
Verdict, stars, colour label — the grid's own write, pair companion included (PATCH /api/assets/:id/rating) |
trash.move { ids } · trash.restore { ids } |
Soft-delete media already rejected (any other id refuses the whole call), or bring them back; the original is untouched and purge stays an admin's step (POST /api/assets/delete). assets.list { trash: true } lists the bin |
tags.list · tags.assign { ids, add?, remove? } |
The tags with their counts; add/remove by name, creating a new tag on first use (/api/tags, POST /api/tags/assign) |
cull.setMany { ids, verdict?, star?, wholePile? } |
The grid's bulk rating, ≤ 500 ids a call; wholePile rates every frame of each id's burst too (POST /api/ratings/bulk) |
Parameters are checked before anything is sent: an out-of-range value or a misspelt key is refused with the field named, never clamped or ignored. Geotag and export are deliberately not commands yet.
- Keyboard:
Ppick ·Xreject ·Uundo ·1-5stars ·←/→navigate ·Escclose - Touch: swipe ↑ = pick, swipe ↓ = reject, swipe ←/→ = navigate
A phone-first surface to pick up culling where you left off and burn down the backlog without thinking about folders. The page lives in the nav rail (and as a PWA shortcut), and answers "what's left to sort?" at a glance:
- Triage progress is now drawn everywhere a session appears — a two-tone
bar (green = picks, red = rejects, the rest still unrated) on every session
list row/card, the detail header, and the Sift tiles (shared
SessionProgress). The Sessions toolbar gains a progress filter (All · To sort · Done) so the not-yet-finished sessions are one tap away. - Resume card: the session you triaged most recently that still has work, surfaced at the top — tap to dive straight back into its deck.
- Rank & filter: order by Recent edits (most recent verdict), Capture date, Completion or Item count (number of live media — flip the direction to "least first" to surface the shortest sessions and knock them out back-to-back), in either direction; filter to To sort / Untouched / Done / All.
- The deck (
/sift/[id]): a "Tinder for photos" stack — swipe right = pick, left = reject, up = skip. Tap buttons mirror the gestures and arrow keys drive it on desktop (←/→verdict,↑/space skip,Backspace/Uundo). Each verdict flies the card off and reveals the next; Undo walks back and reverts the rating. Verdicts hit the samePATCH /api/assets/:id/ratingas everywhere else (so a pair rates as one).- Status bar above the deck: the count still to go (
N left / total), the overall progress bar, and an Open session shortcut — so the live tally and a jump into the full grid sit in the dead space above the carousel rather than the header corner. - Video plays inline: tapping a clip's ▶ badge plays a muted preview right on the card (the whole card stays swipeable); the eye button is what opens the full-screen viewer.
- Sift from the viewer: the peek viewer carries the same verdict buttons
(Back/undo · Reject · Skip · Pick, plus
P/X/S/Ukeys that don't clash with←/→navigation), so you can cull on the big image — and it spans the whole card list, already-sorted cards included, for a second look. - Recent decisions strip: an always-on, full-width virtual history of
just-sorted cards (latest first,
object-containthumbnails) runs along the bottom, so you can see what you decided and re-cast a verdict at a glance. Width-aware (aResizeObserverfeedsreact-window) so a session of thousands only renders the tiles on screen. - On completion: the session is offered up as done with the run's tally and two ways onward — Next session still needing triage (one tap to keep the flow going) or Open sorted session to reopen the one just culled and review the picks before exporting.
- Status bar above the deck: the count still to go (
The same set of actions is reachable from three surfaces, all backed by the
shared endpoints above (AssetActionMenu + lib/assetActions.ts):
- Right-click a thumbnail (gallery + session grids) → context menu with the full set (pick / reject / clear · stars · tag · export · regenerate derivatives · resolve location · delete).
- Detailed viewer → pick / reject / stars / tag plus export, regenerate derivatives, resolve location and delete in the control bar. The info panel surfaces the full metadata (date, size, dimensions + megapixels, duration for video, device, GPS with a map link, resolved place, derivative status, file path).
- Bulk selection (gallery Select mode) → pick / reject / stars, add/remove tag, export, regenerate derivatives, resolve location and delete applied to the whole selection.
Delete is a soft delete — the recycle bin, not the end of the road. It
sets assets.deleted_at so the file is hidden from every listing/export but the
original on the NAS is untouched and fully recoverable
(POST /api/assets/delete { ids, restore: true }). Reclaiming the space is a
separate, confirmed step (the Trash tab → Empty trash); see
Reclaiming space. Export queues a normal
RAW-copy job scoped to exactly the chosen ids. Regenerate derivatives
re-enqueues thumb/proxy generation for the selection (resets them to pending
whatever the current status) — handy after a worker/codec upgrade or a bad
preview; the RAW is read again but never modified. Resolve location
reverse-geocodes the selection on the spot (GPS → place names + the exact-spot
tourist POI) instead of waiting on the batch — see
Places.
Sorting (pick/reject/stars) is only half of winnowing — the point is to actually slim the archive down. Winnow does this in two deliberate stages so nothing is ever lost by accident:
- Recycle bin (soft delete) — Delete (gallery / viewer / bulk), or Move
all rejects to trash in the Trash tab, sets
deleted_at. The shots vanish from the library but the originals stay on the NAS, recoverable (per-item Restore or Restore all). - Reclaim (purge) — Empty trash asks for an explicit confirmation, then
queues a purge job that physically removes the trashed originals and
their cached derivatives, freeing the disk. The asset row is kept (audit +
export lineage):
purged_atmarks the bytes as gone, logged inpurge_log.
The purge runs on the worker with bounded concurrency (PURGE_CONCURRENCY,
spares the HDD) and is resilient: a file already gone counts as reclaimed; a
file that can't be removed (e.g. a read-only mount, so the filed NAS sessions
must be mounted read/write to be freed) keeps its place in the trash with the
reason recorded — nothing else is touched, and you can retry. Disable the whole
capability with PURGE_ENABLED=false. The Trash tab shows the reclaimable
size up front and the result of each purge (freed bytes, anything that failed).
Rapid runs — a continuous burst, an AEB bracket — are grouped into piles so
they stop dominating the grid and can be culled in one gesture. Frames are
clustered at scan time by temporal gap + same device: a new pile starts when
the gap to the previous frame exceeds BURST_GAP_SECONDS (default 1.5 s) or the
device changes; a run of at least BURST_MIN_FRAMES (default 3) becomes a
stack. Unlike a RAW+JPEG or Live-Photo pair (two files of one shot), a stack
is N distinct shots — so stacking is a dimension orthogonal to pairing,
built over logical media: a pile of RAW+JPEG pairs is 5 tiles' worth of frames,
not 10, and each frame keeps its own rating.
Once formed, a pile is classified action (continuous shooting) or
bracket (an exposure-bracketed / AEB run), two-tier, in this order: (1) an
explicit maker bracket tag on any frame (Sony and others stamp one in the
MakerNotes — exiftool's composite BracketShotNumber, absorbing per-brand
variants), else (2) the frames' exposure compensation (EV) actually spreads by
more than BURST_BRACKET_EV_EPSILON (default 0.05 EV) — the fallback that also
catches iPhone and DJI drone bracket sequences, which don't stamp the maker tag.
Anything that clusters as a pile but matches neither is action. Classification
happens once, at pile-creation time; it isn't re-evaluated on its own — a
Restack (see below) after a rescan reclassifies it.
- In the session grid a collapsed pile is one stacked cover tile (deck
edge +
⧉ Nbadge,± Nin the amber "star" color for a bracket pile). Tap to expand in place: the frames splice into the grid (accent rail,▴ Nto collapse) and work like any row — viewer, selection, context menu. The gallery shows the same badge (display-only). - Find them at a glance: the gallery filter panel has a Bursts section
(In a burst / Standalone,
stacked=) — combined with the collapse, In a burst turns the grid into one tile per run, so every burst in scope is listed side by side. It stacks with every other filter (device, date, session, rating…), so "the drone bursts of last July" is one query. - Pile actions (context menu of any pile frame, incl. inside the viewer),
all explicit — rating a frame normally never cascades to its pile:
Keep this one, reject the rest · Keep sharpest, reject the rest (the
sharpness analysis picks the keeper; run Detect faces & text first) ·
Pick / Reject / Clear whole pile · Export pile. Whole-pile verdicts
reach every live frame and each frame's RAW/Live companion; trashed frames
are never touched. When a pile is expanded, a
◆chip marks its sharpest analyzed frame. - Restack (session header): re-clusters the session from scratch with the current thresholds. The scan-time clustering is deliberately incremental — it never reshapes an existing pile — so threshold changes and frames indexed after a pile formed only take effect through this action. Non-destructive: verdicts/stars/tags are per-frame and survive; only membership and covers are recomputed. Trashing a pile's cover never hides the pile — the next live frame stands in.
Gallery page: virtualized grid (react-window — only the visible rows are in the DOM, handles 30k+) over all the assets, with a cumulative filter panel (combined with AND):
- Search (
q=): free-text over the file path — filename and folder. Whitespace splits the query into tokens, each an AND substring match (case-insensitive). Debounced field at the top of the filter panel; mirrored to the URL like every other filter. - Calendar: year / month / day (multi-select) + date range
- Device / EXIF: device, camera model, lens (multi); ISO, focal length, aperture ranges
- Location: country, region, department, city, place (multi) — the reverse-geocoded place names, see Places
- Type / format: photo·video, extension (multi)
- Size (MB range), GPS present, verdict, min rating
- Live Photos (
group_kind=live_photo): show only iPhone Live Photos (the still +.movpairs) - Bursts (
stacked=1/stacked=0): In a burst keeps only the frames that belong to a burst/bracket pile, Standalone only the shots taken on their own. Because the grid collapses a pile to its cover, In a burst lists one tile per run — the shortlist of every burst shot in scope, ready to review or export. The panel states how many piles and frames are in scope, and the toggle stays hidden on a library that holds no pile. A second Action / Bracket toggle (burst_kind=) narrows by pile kind; it stays hidden until the library holds at least one bracket pile.
These dimensions are materialized and indexed in the database (migration
0003: capture_year/month/day/date populated by trigger + indexes on device,
ext, media_type, file_size, camera_model, lens, iso, focal_length, aperture).
The available values/counts come from GET /api/facets; filtering is therefore
100% indexed SQL, with no on-the-fly computation. The q= text search matches
rel_path (which carries both the folder and the filename) and stays fast on a
large library via trigram GIN indexes (pg_trgm, migration 0010). The
Location dimensions follow the same pattern — reverse-geocoded names
denormalized onto indexed place_* columns (migration 0020).
Alongside the grid, every gallery has a Calendar view: a month-at-a-glance
wall calendar where each day that holds media shows a cover thumbnail and a
count. It respects the current cumulative filters (device, type, tags…) via
the shared Filters/Browse aside, opens on the most recent month with media,
and clamps year/month navigation to the filtered span. Picking a day pins it as
a date_from=date_to filter and drops back to the Grid to review that day —
the same hand-off the map's Show in grid uses. GET /api/assets/calendar
returns one row per capture date ({date,count,cover_id}) for the visible
window plus the overall bounds, so a month renders in a single request.
A frame belongs to the day on the photographer's wall clock, not the UTC one.
Until migration 0046 capture_date was the UTC day, so anything shot before
10:00 in Queensland (08:00 in Perth) was filed on the day before — the
sunrises, and every morning of a road trip, which then pulled the previous
day's position toward the next stop.
captured_at still holds exactly what the file states; beside it each frame
carries the UTC offset of the place it was taken in, and where that offset
came from, strongest first: the frame's own position (zone looked up
offline, DST included), an imported GPS track, the nearest frame from any
device shot within 12 h (the phone in the pocket knows the zone the camera
forgot), and last the zone the camera wrote — the weakest, since a camera
left on its home zone abroad states a wrong one. A wall clock with no zone
keeps its own date. The indexer decides this for every new or modified file;
Settings › Pipeline › Dates & places repairs a library indexed before it,
as two numbered steps. Step 1, Read the capture times, is one verb while
frames remain whose time cannot be classified from the database (indexed before
the repair, or a clip an earlier read mislabelled): Read their dates opens
only each original's header, at the scan's pace, paused by its pause, and a
second click resumes — then the step says Done. Recompute the days (the
database-only passes) and a Dry run sit under more. Originals are never
written, and clearing the offsets restores the old days exactly.
npm run capture-days [-- --apply [--reread]] is the same job from a shell.
GPS track import (step 2 of the same page). A track recorded beside the
cameras — a Polarsteps export, a GPX, or a JSON list of {lat, lon, time} —
places every frame of its span that has no position, by its
capture instant: between two fixes on the move (≤ 60 min apart, never across a
flight), between two fixes at the same spot (≤ 2 km apart, up to 12 h — the
traveller stayed), or on the nearest fix within 15 min; a frame in a gap of the
track gets only the zone it was in, for its capture day. These positions are
gps_source='track': trusted like a camera fix for a day's position, never
written into an original, kept through re-indexing. A frame placed by its own
file is never moved — when the track puts it more than 25 km away at that
instant it is listed as a conflict, the signature of a camera clock set
wrong — and positions set by hand or accepted from a folder suggestion are kept.
A Polarsteps export is two files and the step shows one slot for each —
Positions (locations.json, required) and Steps (trip.json, which carries
each day's time zone and the trip's name) — saying what every file is as soon
as it is chosen, whichever slot took it, and refusing a file that is neither.
The import is recorded under the trip's or the GPX's own name. Preview writes
nothing; Apply records the import; Undo takes back exactly what it wrote. Frames indexed before the local-day repair are skipped until
their date tags are re-read — the report offers to re-read just its span.
Off by default. The Timeline is behind a feature flag and ships disabled: the chapters are re-derived on every request, so what a client reads today can change under it tomorrow, and the cut rules still owe a rework. Turn it on in Settings › Features to look at work in progress; with it off, the page and
GET /api/assets/timelineboth answer404.
The Calendar answers what did I shoot that day, the Map where. Neither
tells the story of a period: a year abroad lands in dozens of session folders,
and a session is a directory on disk, never a leg of a trip. The
Timeline is the third axis — a chronological stream cut into chapters
("Sydney, 3 → 11 March") that cross those folders, each listing the folders it
merges so the merge is visible, with a sticky per-month spine to reach any
month of a year in one gesture. It carries the Incoming/Gallery/All picker like
/gear and /search, tiles open the same viewer with the same P/X/0-5
shortcuts, and Ouvrir dans la grille drops the chapter's date span into the
grid.
Chapters are derived on every request, never stored (lib/timeline.ts):
one ordered SQL scan (LAG + a running sum of breaks) returns one row per
run — a 100k library collapses to a few hundred rows — then a pure JS pass
absorbs the crumbs, and one more query picks a cover and a spread sample per
chapter. Three cut rules share that scan:
- Lieu — a different place starts a chapter. Merges a nine-day stay across three folders; shatters a day on the road through six villages.
- Temps — a silence longer than
gaphours starts a chapter. The road day becomes one chapter; the stay breaks every night. - Hybride (default) — a place change always breaks; staying put only after
72 h (sleeping there is not leaving); then runs under
absorbmedia fold into their neighbour when they belong to the same outing.
The place granularity is picked automatically (Région → Département → Ville, the first level giving a readable 6–30 chapters), shown as a chip and pinnable — an automatic cut nobody can see or pin is one nobody trusts.
Two things worth knowing. Days are read locally: a chapter's median
longitude gives the offset it labels its span with (round(lon / 15)), stated
on the chapter, approximate at zone borders on purpose — the per-frame day
beneath it is exact wherever the place is known (see Local capture day
below). And a chapter
holding no GPS at all is named from its neighbours in time and badged
lieu déduit: that inference lives in the response only. POST /api/assets/geotag writes coordinates into the original file's EXIF, and a
guess is not the human confirmation that write-back requires.
Editing a chapter (Nommer / Modifier on the card) never stores the
chapter: it stores a correction on top of the derivation (migration
0040), so re-indexing or re-deriving stays safe — the same reasoning that
keeps ratings per asset so a burst pile can be re-clustered. Two corrections
exist. A named span [starts_at, ends_at] makes everything inside it one
chapter with that name and, optionally, a chosen location (Nominatim
autocomplete through /api/places/search); renaming a chapter and merging two
neighbours are both "draw a span". A forced break at an instant splits;
the dialog offers the chapter's local mornings as cut points. Every correction
has its undo (Réinitialiser, Recoller au précédent). A span's location
describes the chapter and is never copied onto its media — placing the
chapter's GPS-less media there is a separate, explicit geotag with its recap.
From an inference to a coordinate — one path. A chapter badged lieu
déduit carries Confirmer le lieu…, and a chapter with a chosen location
and GPS-less media carries Placer N médias sans position…; saving a
location in the dialog offers the same thing as a second step. All three
open the existing manual geotag flow — the map picker where no location is
chosen yet, then the per-media before/after recap — and it is POST /api/assets/geotag, at the end of that recap, that writes. The GPS-less
media are enumerated through the shared feed with has_gps=0 (the filter
became tri-state for this; has_gps=1 still means "geotagged only"). The
lieux déduits chip narrows the stream to the chapters still waiting for
that confirmation.
Off by default — a feature flag, like the Timeline, but for a different reason: nothing here is unfinished, it is simply a step-back view rather than part of the daily cull. Turn it on in Settings › Features.
The Calendar answers what did I shoot that day, one month at a time. The Map plots a marker per asset and caps at 10 000 points. Neither can cross the two — "where was I in the summers of 2023–2025", "when did I shoot this valley". The Heatmap is that crossing, read four ways behind one segmented control:
| Reading | What it draws | What it gives up |
|---|---|---|
| Both | A years-deep calendar above, a binned map below, cross-filtered: brush a span and the map redraws; click a bin and the calendar redraws. | Two panels compete for height; on a phone one is always below the fold. |
| Matrix | Places down, months across — the crossing as one object, read like a timetable. Corse lights up every August; Islande is one week and then five years of nothing. | Geography: two rows can be neighbours or 9 000 km apart and the grid says nothing. |
| Map | The map at full width, months on a scrubber under it. Best for the question about absence — where have I not been back to. | A month-tall bar cannot name the one unculled week. |
| Tinted | One calendar, each day wearing its place's colour; a run of matching feet is a trip. | Four tints is the ceiling — everything past the top few reads as "elsewhere". |
One measure colours all of them, which is the point of the shared control:
- Backlog (default) — what has never been culled. The measure that is uniquely Winnow's: a map of "these three weeks have never been looked at" is a work queue, not a statistic.
- Volume — frames. The portrait rather than the to-do list.
- Keepers — frames rated
pick: not where you shoot most, but where you shoot well. - Keeper rate — picks ÷ frames, with a floor: under 20 frames a cell has no rate and is drawn neutral, because four frames and two picks is not a 50 % day. The floor is stated on screen.
The bins are free: reverse geocoding already snaps every coordinate to a
places(cell_lat, cell_lon, precision_m) cell at ingest (5 km by default), so
the map is one GROUP BY with no clustering pass and no PostGIS — and, unlike
/api/assets/geo, no 10 000-point cap. The trade is that a bin does not refine
as you zoom: its size is whatever the geocoder stored.
Days are each frame's local day where its place is known and the UTC day
otherwise (Local capture day below), and the page says so. Undated and ungeotagged media are
counted beside the view, never silently dropped. Everything is in the URL —
the reading, the measure, the granularity, the brushed span, the picked bin — and
Open in the grid hands off with the same date_from/date_to pair the
Calendar's day-drill already uses.
Two aggregates back it: GET /api/assets/heat/days (one row per capture date)
and GET /api/assets/heat/places (per place, by month — the map and the matrix
from one read). Both are one scan, and both run with SET LOCAL jit = off:
the pair-collapse predicate they share with the Calendar pushes the plan past
jit_above_cost, and on an 87k-asset library the LLVM compile cost 1 s against
28 ms of actual query. Together those two changes took the page from 5.5 s to
0.23 s.
Every gallery (Incoming → Browse, Final, and /gallery) has a
Grid / Calendar / Map toggle. The Map plots one point per geotagged asset — so you can see where
the media are — over OpenStreetMap tiles (source configurable via
NEXT_PUBLIC_MAP_TILE_URL). The points respect the current cumulative filters
(device, date, type…), and GET /api/assets/geo returns just {id,lat,lon} so
even a large library plots in one request (capped, with a truncated flag).
Pick a zone, then act on it. Either Select visible area (use the current
viewport — works on touch too) or Draw box (drag a rectangle). The map shows
how many media fall in the zone and lets you Pick · Reject · Export them in
one go, or Show in grid to review the thumbnails. A clicked point pops its
thumbnail. The zone is just a bounding box that becomes a regular cumulative
filter (bbox=w,s,e,n), materialized + indexed in the DB (migration 0010:
gps_lat/gps_lon populated by trigger from the gps JSONB), so it stacks with
every other filter and scopes the grid, the selection, and exports — the picks
that drop into Capture One are exactly the media from that area.
The map answers where on a tile; Places answers it by name. A background job turns the GPS coordinates already indexed into place names — country · région · département · city, plus a tourist POI on demand — so you can filter the gallery by "Bretagne" or "Quimper" the same way you filter by device or lens. It's a batch feature: an existing library is backfilled without re-scanning the NAS (the coordinates are already there).
- Cell cache, not one lookup per photo. Coordinates are snapped to a grid
cell (default ~5 km,
geocodePrecisionM) and every asset in the same cell shares a single reverse-geocode call, cached in theplacestable. A RAW+JPEG pair (identical coordinates) or a whole trip therefore costs one call — a ~90k-media library collapses to a few hundred/thousand cells, which is what keeps a free, rate-limited provider viable. - Provider is swappable. Default is the OpenStreetMap Nominatim public
instance (free; ~1 req/s and no bulk — respected via the cell dedup + the
geocodePerHourdrip-feed). PointGEOCODE_BASE_URLat a self-hosted Nominatim or a compatible service (LocationIQ, Photon) for heavier use, no code change.GEOCODE_USER_AGENTis required by Nominatim's policy. - Filterable, indexed. The names are denormalized onto
assets(place_country/region/county/city+place_poi) behind indexes (migration 0020), so the Location facets/filters are 100% indexed SQL like every other dimension; the full provider payload is kept once inplaces.raw. The resolved place also shows in the viewer's metadata panel. - Batch, automatic, or on demand. Backfill the existing library with
npm run geocode-backfill(--forcere-resolves, e.g. after changing the precision); new geotagged imports are geocoded automatically. Or run it now from any media menu — the Resolve location action (context menu / viewer / bulk) resolves the picked media immediately and, at the exact coordinate, fills the tourist POI a 5 km cell can't. Runs off its own BullMQ queue + worker; tunable live viaPATCH /api/settings(geocodePrecisionM,geocodePerHour), and the whole feature toggles withGEOCODE_ENABLED.
Places answers where; Faces & text answers who is in frame and what the
image says. A background job sends each media's existing derivative (the
photo proxy, or the poster frame for a video) to a self-hosted
machine-learning container — the immich-machine-learning sidecar the NAS
already runs for Immich — and stores what it sees: the detected faces
(bounding box + recognition embedding) and the text read in the image (OCR).
Like Places, it's a batch feature: the existing library is backfilled
without re-reading the RAWs (the WebP proxies are already in the derivative
cache, and that's all the models need — face detection looks at ~640 px, OCR at
≤736 px).
- Reuses the container you already run. No Python, no model management in
this stack: Winnow is a plain HTTP client of the container's
/predictendpoint (ML_BASE_URL, default port 3003). Faces and OCR ride one request per media. Immich is AGPL-3.0, but consuming its HTTP API from a separate process carries no license obligation — the container runs unmodified.⚠️ That API is Immich-internal and unversioned: pin the container image tag and re-check after upgrades (ML_OCR_ENABLED=falseagainst a pre-v2.2 image, which has no OCR). - Paced like everything else. Runs off its own BullMQ queue + worker
(
ML_CONCURRENCY, default 1) and a "Faces/OCR rate" slider on the Pipeline page (mlPerHour, default 1200/h — an 80k library drips through in ~3 days without pinning the box; 0 = unlimited). New imports chain automatically after derivative generation; the analysis lifecycle (ml_status) mirrors the derivative one, errors included. - Filterable, indexed.
face_countandocr_textare denormalized ontoassetsbehind indexes (migration 0021), so the gallery gains a Faces facet (has faces / no faces / exact count) and a Has text (OCR) toggle — and the free-text Search (q=) now matches the text read in the image as well as the file path (trigram-indexed, stays fast). The viewer's info panel shows the face count and the text; the Detect faces & text action (context menu / viewer / bulk bar) re-analyzes a selection on demand. - Blur & near-duplicates come free. The same job also computes two local
metrics with sharp (no container involved): a sharpness score (variance of
the Laplacian — low = blurry; a Sharpness range filter in the gallery
surfaces the soft shots, and the score shows in the viewer's info panel) and a
64-bit perceptual hash (dHash). Unlike the byte-identical
content_hashdedup, the perceptual hash catches near-duplicates — the same frame re-exported/resized, burst neighbours, a slightly different crop: the viewer's info panel gains a Similar strip (GET /api/assets/:id/similar, ranked by Hamming distance) to answer "which of these do I keep" on the spot. - Embeddings are kept (
asset_faces.embedding, 512-dim ArcFace, JSONB — pgvector-ready). Grouping faces into named persons is a natural next step and will need no re-inference over the library. - Semantic search (CLIP). The same
/predictcall also returns a CLIP visual embedding of the derivative (one more task on the existing round trip — no extra call, no RAW re-read), stored inasset_clipvia pgvector. The Search page (in the rail) then takes a natural-language query — "sunset over the sea", "people laughing at a table" — embeds it with the same model's textual head and ranks the library by cosine distance (GET /api/search). Toggle withML_CLIP_ENABLED; pick the model withML_CLIP_MODEL(ViT-B-32__openai= 512-dim, CPU-friendly — visual and textual heads must be the same model). Back-fill embeddings over an existing library withnpm run ml-backfill -- --forceafter enabling it. pgvector is optional: the compose Postgres image (pgvector/pgvector:pg16, a drop-in superset ofpostgres:16— existingpgdatakeeps working) provides it, but migration 0030 skips the table gracefully on a Postgres without the extension (a stockpostgres:16-alpine, a managed instance), so nothing else breaks — Search just reports itself unavailable until you install pgvector (CREATE EXTENSION vector;, re-run migrate, back-fill). - Not covered (yet): closed eyes. The container returns face boxes, scores and embeddings but no facial landmarks, so an eyes-open/closed verdict can't be derived from it — that would take a small dedicated landmarks model (future work, noted in V2/V3).
- Batch, automatic, or on demand. Backfill with
npm run ml-backfill(--forcere-analyzes everything, e.g. after a model upgrade); new media are analyzed automatically once their derivative is ready. Toggle the whole feature withML_ENABLED(off by default — pointML_BASE_URLat your container first).
The Gear page (/gear, in the rail) is the shelf: every camera body the
library was shot with and, hanging off each one, the glass it was used with —
all of it drawn and counted. Bodies come first because that is how the kit is
actually held: a lens count only means something once you know which body it was
mounted on. It is the inverse of the gallery's device/lens filter chips — instead
of "narrow the grid by camera" it answers "what have I shot with, and how
much" at a glance, then links each piece of gear back to its frames.
- Incoming / Gallery, like the Library tabs. The library has two halves and
most gear has frames in both, so a merged tally would always promise media
the linked grid can't show. The shelf carries the same segmented tabs, and the
choice drives both the counts and where every card points
(
/library/incoming/grid?device=…&lens=…or/library/gallery?device=…&lens=…; both tabs seed their filters from the query string). Gear with nothing in the active half is dropped rather than drawn at zero — its card would open an empty grid — and each card names what the other half still holds ("3 more in the Gallery"). The choice is remembered between visits. - One lens, one card (
src/lib/lensLabels.ts). Bodies and EXIF tools disagree about whether to prefix the maker and whether to append the part number, so the same glass arrives as bothFE 50mm F1.4 GMandSony FE 50mm F1.4 GM (SEL50F14GM)— drawn twice, with its count split in half. Spellings that normalize to the same lens merge onto one card, which keeps every raw string and filters on all of them (?lens=a,b), so the count and the grid still agree. An optical difference is never merged away:+ 2X Teleconverterstays its own lens. The card's tooltip lists what was folded in, and Sony's----placeholder reads as Unknown lens. - Counted like every other counter (
GET /api/gear,src/lib/gear.ts): live assets only (a trashed frame stops inflating a body's tally) and logical media, so a RAW+JPEG pair counts once. Only roots the Library can actually show are counted — anexportstaging root belongs to neither tab. Grouping stays on the raw EXIF string — the value the gallery filters on — so a card's count and the grid it opens can never drift; prettifying (lib/cameraLabels.ts) happens on display only. Each card also carries the date span it was in service and the photo/video split; frames whose files carry no lens tag are named under the body ("3 frames without a lens tag") rather than hidden, so the numbers add up. - The artwork is generated, not drawn (
src/lib/gearArt.ts). A library can hold any camera anyone ever pointed at anything, so per-model illustrations are a losing game: each EXIF name is classified into a body archetype (reflex · mirrorless · rangefinder · compact · phone · drone · action · camcorder) with a rough footprint, and the SVG is drawn from those numbers — unknown cameras included, and to scale against each other (a 5D towers over a GR IIIx). - Lenses are drawn from the data. No lookup table: barrel length follows the focal length and girth follows the entrance pupil (focal ÷ aperture, the physical reason fast glass is fat), so a 56mm f/1.2 draws short and fat, an f/2.8 pancake flat, a 70-300 long and stepped. The focal range is read off the name where it's stated and off the recorded EXIF otherwise (which is also how a zoom betrays itself when its name doesn't say so), the engraved aperture scale starts at the lens' real widest stop, and only lenses that have one get an aperture ring. Sizes use a compressed scale: the ordering is truthful, the ratio softened so a pancake stays legible beside a telephoto.
- Sort by Most used (default) or Recent, and the whole shelf is line-art
in
currentColor, so it inks itself in both the paper and the night theme.
The dedicated Volumes page (/volumes, in the rail) is the registry of every
directory Winnow indexes or tracks — a table with one row per folder, its
type, the session/media counts, and per-row actions (re-index,
remove). It replaces the old free-text "index this path" field on the
Library tab (which made it far too easy to scan / — a recursive walk has no
depth limit or boundary, so that pulled in the whole filesystem, finals
included).
- Type decides how a folder is interpreted (maps to
roots.kind): Incoming (source, cullable), Final (finals, view-only), Export (export, listed for visibility only — never walked). Editable inline. - Add folder opens a modal with a server-side folder picker (
GET /api/fs): browse the NAS and click a folder instead of typing a path — navigating into a folder also selects it. Navigation is confined to the browse roots (BROWSE_ROOTS, default/nas, plus the configured volume dirs), so the OS tree (/etc,/usr, …) is never reachable and symlinks can't escape the bounds. An Enter path tab keeps manual entry as a fallback. Either way a type selector decides how the folder is treated, and the same guards (reject/and system dirs, refuse a path that overlaps an existing volume) backPOST /api/index/scanandPOST /api/roots. - Origin badge (
env/manual): the four env vars (INCOMING_DIR/FINALS_DIRS/EXPORT_DIR) seed volumes at worker bootstrap and suggest the type per directory; the table is the editable source of truth on top.FINALS_DIRSis already a list, so several final folders are supported today — the table simply makes them visible. - Remove deletes the volume + its sessions/assets/ratings from the DB
(
ON DELETE CASCADE); the originals on the NAS are never modified (Winnow only reads). An env-seeded volume reappears on the next worker bootstrap.
The dedicated Pipeline page (/pipeline, in the rail) exposes the full
control panel + stats bar, refreshed every 5 s (GET /api/stats). The
Library header keeps only a compact stats strip (value+label chips on
desktop; a single summary chip that opens the detail in a popover on phones, so
the bento no longer eats half the screen) — each counter links through to its
dedicated Pipeline triage page:
- Counters: number of indexed media, scan (folders in the indexing queue), analyzed (derivatives ready), pending (to analyze), plus failures (always shown) — to see at a glance what's left to do.
- Triage sub-pages (tabs under
/pipeline): each counter has its own page with a live, actionable list:- Media (
/pipeline/media) — every indexed asset; open the full preview or soft-delete (the RAW original is never touched). - Scanning (
/pipeline/scanning) — the live scan queue; remove a stuck/unwanted folder job (active scans can't be removed mid-flight). - Pending (
/pipeline/pending) — media awaiting analysis; regenerate, skip (take it out of the pipeline — honoured even by an already-queued job) or remove. - Analyzed (
/pipeline/analyzed) — latest derivatives processed; re-create a bad preview or remove the media. - Failures (
/pipeline/failures) — see below.
- Media (
- Pause / resume: suspends indexing and derivative generation
(
POST /api/scan/control). The pause is persisted in Redis (queue.pause()) and via a database flag, read by the indexer to stop mid-scan; resuming re-enqueues the roots to finish an interrupted scan (incremental: already-known files are skipped). - Hourly rates (sliders): max number of files scanned and derivatives
analyzed per hour (
PATCH /api/settings,0 = unlimited). Spread out drip-by-drip via a shared Redis limiter — useful to spare the NAS's full HDD without blocking the app. - Rescan interval (slider): minutes between two automatic incremental
re-scans of the watched volumes (
rescanMinutes, default 60,0 = off). There is no filesystem watcher on the NAS mounts (inotify doesn't propagate over SMB/NFS), so this cadence is what bounds how stale the library can get: new/changed files are picked up and deleted originals detected without a manual re-index. Cheap by design — an incremental scan onlystats unchanged files — and coalesced, so ticks never stack scan jobs. - Incoming / inbox priority: imports (incoming) and the inbox go ahead of ordinary scans/derivatives (BullMQ priority). A long ordinary scan is preempted as soon as an incoming scan is waiting, then re-enqueued.
- Scan coalescing: indexing is incremental + idempotent, so every trigger (bootstrap, import, resume, retry, preemption…) coalesces on the root id — at most one pending scan per root. A more urgent request promotes the queued job's priority instead of stacking a duplicate, and the worker reconciles any leftover duplicates at startup. This stops the same folder from piling up several times in the scan queue.
Videos get derivatives like photos: WebP poster (grid thumbnail) + H.264
mp4 proxy that's playable/seekable in the viewer (service route with Range
requests). The worker image bundles ffmpeg.
Hardware acceleration (optional): VIDEO_HWACCEL=vaapi encodes on the Intel
iGPU (share /dev/dri with the worker container — already wired in
docker-compose-optiplex.yml). Hardware encoding fails? automatic fallback
to software libx264. Defaults to none (software) → works everywhere.
Sony cameras (A7C II / XAVC-S) write a small metadata companion next to every
video clip — C0001.MP4 → C0001M01.XML (the non-real-time metadata: real
capture time, GPS, recording mode, codec…); other cameras drop a per-clip
thumbnail C0001.THM. These are not media — never indexed as their own
assets, never given derivatives — but Winnow keeps each one tied to its clip
so it travels with the video (the same "carry the companion" model as RAW+JPEG
pairing). Detection is by name (<base>M01.XML / <base>.XML / <base>.THM),
recorded in asset_sidecars (migration 0015). Concretely:
- Import files the clip into the incoming archive and carries its sidecar(s) alongside (renamed to track any collision-suffix on the clip, so the link survives) instead of orphaning them.
- Index detects the sidecars sitting next to a freshly indexed clip and records them on the video asset (idempotent — re-indexing never duplicates).
- Export copies each clip's sidecars next to the exported video and records
the lineage (
exports.kind = 'sidecar'). - Reclaim/delete removes a clip's sidecars together with the original when purging the trash or deleting a session's files — no satellites left behind.
The viewer's info panel notes when a clip carries sidecar files.
You cull your sources (the RAW captures) on one side, and keep your finished edits in the Final volumes on the other — one folder per shoot, exported from whatever editor you reached for (Capture One, Photomator, Lightroom…). Reconciliation links each edit back to the source it came from, so you can jump between before and after at a glance.
- Tool-agnostic key. The match keys on what every editor preserves on export:
the filename basename (your exports keep the source name) plus the original
capture time (
DateTimeOriginal). The capture time disambiguates a filename a camera reuses across cards/years (aDSC00123in 2024 vs 2026) — no Capture One specifics, no camera serial needed. A final that matches several captures is left unlinked rather than guessed. - Retroactive + automatic. Both sides are already indexed as
assets(Final volumes are walked, view-only), so reconciliation is a pure DB pass over the existing library — it works on everything already indexed. It also runs after each scan (a Final scan matches its own new edits; a source scan lights up finals that a freshly-indexed RAW now matches), and on demand viaPOST /api/reconcile { root_id? }. Idempotent: an existing link is never disturbed, and a file that arrives later is picked up on the next pass. - Where it shows. The viewer gains a Before/After toggle (swap the
on-screen media between the edit and its source, like the RAW/JPEG toggle), and
the panel's Before / after facet filters each side. The link has a
direction, so each surface only sees one end of it and the facet shows only
the half that can match: on Incoming (the captures) Has an edit /
Not edited yet (
has_edit=1|0) — the shots you've published, and the backlog you haven't; on the Gallery (the finals) Linked to an original / No original found (is_edit=1|0), the second listing the edits reconciliation couldn't match. Each axis is hidden entirely until its count is non-zero in scope. The link lives onassets.original_asset_id(edit_matchrecords how it was made:name_date/name).
An export answers "where do the keepers go?", and there are two honest answers. The export folder target hands you the files you go on to develop (Capture One, Lightroom…). The Immich target uploads them to the library you go on to look at — the one on your phone, the one you share from. Winnow stays the darkroom; Immich stays the archive.
This closes the loop the rest of the app already assumes: the Final volumes Winnow indexes read-only are Immich's output (Volumes), and the ML analysis already borrows Immich's machine-learning sidecar (Faces & text). Until now the last hop — getting the picks into Immich — was the manual one.
Setup. Off by default. Point it at your server and give it a key (Immich → Account Settings → API Keys):
IMMICH_ENABLED=true
IMMICH_BASE_URL=http://immich-server:2283 # server root, WITHOUT /api
IMMICH_API_KEY=<your key>
IMMICH_ALBUM_MODE=job # job | fixed | noneIMMICH_ENABLED=true without a key fail-fasts at boot, like every other
incoherent variable. Once it's set, the export modals grow a Destination
choice (session picks, gallery selection, map area — the same modal everywhere);
with Immich off there is only one destination, so the picker stays hidden and the
flow is exactly as it was.
What it does.
- Uploads copies through the public REST API — never into Immich's storage,
its database or its filesystem layout. It's the documented, versioned API with
an ordinary API key, so an Immich upgrade can't corrupt anything: the worst
case is an HTTP error on the export job. (Contrast
ML_BASE_URL, which speaks Immich's internal ML endpoint and has to pin a container tag.) The NAS originals are read, never moved or modified. - Albums.
job(default) files each push into an album named after the export,fixedalways usesIMMICH_ALBUM_NAME,noneuploads to the timeline only. An album of that name is reused, so re-exporting a session tops it up instead of littering the library with "session-picks (2)". - Idempotent. Immich dedups by checksum: a file it already holds comes back
as the existing asset id, so re-pushing an export never duplicates anything.
IMMICH_PRECHECK(on by default) asks first — one SHA-1 per file against/assets/bulk-upload-check— so a re-push sends no bytes at all. Turn it off when Winnow and Immich share a disk and the hashing costs more than the transfer. - Live Photos stay live. Winnow already treats an iPhone still + its
.movas one logical media; the push uploads the motion clip first and links it to the still, so the pair lands in Immich as a Live Photo — not as a JPEG and an unrelated video. - Sidecars don't travel. Immich's upload only accepts an XMP sidecar, and a
DJI
.SRTflight log or a Sony.XMLisn't that. They're skipped and counted in the job result rather than failing it — they still ride along with the export-folder target, which is where they're actually useful. - Sequential, like the copy target: the NAS is one spinning disk and
EXPORT_CONCURRENCYalready bounds how many jobs run at once, so parallel reads would cost more in seeks than they'd win in upload overlap.
Same lineage, different output. A push writes the usual exports rows —
kind='immich', output_path NULL (nothing local to download), output_key =
the remote asset id — and marks each source exported. On the Exports page
the card reports N uploaded, M already there, links to the album, and each file
links through to the media in Immich. Deleting the export drops Winnow's
record and reverts the shots to triaged; the media stay in Immich (the
confirmation says so). The destination is probed while the modal is open, so an
unreachable server or a bad key is reported there rather than as a job that
fails ten minutes later.
Everything that failed is listed in one place, with the error message to debug, and a "retry" button per family:
- Analyze (derivatives):
assets.derivative_status='error'— retroactive. Beyond Retry, each row (and the selection / the whole family) can be Deleted — a soft delete to the trash for a derivative that can never be rebuilt: the original was removed by hand, or it's a junk file that should never have been indexed (a Synology@eaDirthumbnail,#recycle…). A soft delete never touches a file on disk, so it is safe even on a Final (view-only) volume and is the right move once the originals are already gone. Empty trash afterwards drops the leftover thumbnails/proxies and reclaims the rows — and the purge now accepts an orphan on a view-only volume when its original is confirmed absent (a present Final original is still refused, untouched). For a media whose derivative wasreadybut whose source has since vanished, Verify integrity (Missing files tab) flags it first — see below. - Scan (indexing):
scan_failurestable (per-file failure) — from now on; previously only a counter existed, nothing was persisted. - Import: per-file errors of the batches (
import_batches.result) — retroactive. Failed files are quarantined (inbox/.failed/) so they stop looping; retrying re-imports them. - Missing files (integrity): indexed media whose original is no longer on
disk (deleted by hand, cleaned-up empty files…). The indexer only ever walks
files that exist, so these used to linger in the gallery/sessions forever with
a derivative that could never be rebuilt. Now every complete scan diffs
the walk against the index and re-stats each absentee individually
(
lib/integrity.ts): a confirmed-gone original is flagged (missing_at) and auto-trashed — the reversible soft delete, so it leaves every grid immediately but nothing is lost if the detection was wrong. Two safety nets: an unreachable root skips the pass entirely, and a mass disappearance (an unmounted volume looks like everything vanished at once) only flags, never trashes. A file that reappears (NAS remounted, restored from backup) is restored automatically on the next scan. The tab lists the whole set with Re-check (re-stat now, restore whatever answers), Restore (un-trash by hand) and Purge (irreversible cleanup: drops the leftover derivatives, stamps the row — there is no original left to lose). Verify integrity queues the full sweep (POST /api/integrity), which also repairs 'ready' assets whose thumb/proxy object went missing from the cache by re-enqueuing their generation. A file that was moved rather than deleted is the one case none of that repairs. Asset identity is the path, so reorganising folders inside a volume reads as two unrelated events that lock each other: at its new path the file collides with the content hash its own old row still holds and is dropped as an unverifiable duplicate (the verification reads the old path, which is gone), while the old row is flagged missing and auto-trashed. Nothing detects a move today, and the state is stable — rescanning repeats it verbatim, and Restore puts the row back still pointing at the dead path. Purging is the wrong reflex: it does not release the content hash, so the moved file stays unindexable forever. Repair it from Moved, not deleted at the bottom of this tab: Scan for moved files queues a dry pass and reports exactly what it would do, then Relink applies that same match. Both are queued jobs (the pass walks the whole volume) and the section survives leaving the page or locking your phone.npm run relink-movedis the same thing from a shell (dry run; add-- --apply). It matches each orphaned row to the file that now holds its content — size prefilter, then the same partial hash the indexer dedups on — and moves the row onto it, keeping its id and therefore its rating, tags, pairing, burst and edit links. Nothing on disk is touched: only the library's idea of where each file lives. Purged rows are recovered too — the purge only destroyed the derivatives, the ML rows and the sidecar rows, all of which the relink re-queues. - Deduplication (audit + triage): copies of the same bytes are grouped by
content. Each group lists every place that content lives — the library's
indexed copy (its thumbnail stands in for the group) and any extra copies on
disk — making no assumption about which is "the original". The user picks the
survivor with Keep only this: the rest are hard-deleted and, when the
survivor is an on-disk copy, the library asset is relinked onto it (its id,
rating, tags and derivatives are preserved — the bytes are identical), so a
single media remains. Other hands-on actions: filter by path (e.g.
trashto isolate Capture One's trash folder), download any copy to verify it, and delete on-disk extras one at a time or by selection. Everything is behind a confirmation and guarded: file deletes are confined to the browsable area, only ever touch a recorded copy or the relinked-away original, and a false collision (distinct content that merely shares a partial hash) is never grouped, collapsed, or deletable — it's indexed on its own and listed apart for audit only.
The RAWs are mounted read-only and never lost, but every bit of curation — verdicts, stars, tags, soft-deletes, the indexed facets and the source→export lineage — lives only in Postgres. So the database is the one piece of state that needs a backup.
A backup sidecar (in both compose files) runs next to Postgres and, on a
schedule (default daily, BACKUP_INTERVAL), takes a compressed pg_dump,
names it winnow-<UTC timestamp>.sql.gz, and prunes dumps older than
BACKUP_KEEP_DAYS (default 14). It shares the postgres:16-alpine image, so the
dump tool always matches the server. Dumps land in ./backups (dev) or
${WINNOW_DATA:-/opt/winnow}/backups (Optiplex).
docker compose logs -f backup # watch it run
./scripts/pg-backup.sh # ad-hoc dump (e.g. before a migration)
./scripts/pg-restore.sh ./backups/winnow-<ts>.sql.gz # restore (stop app/worker first)Full schedule/retention config, the host-cron alternative, off-box copy
guidance (the Optiplex dumps sit on the same disk as pgdata — copy them to the
NAS for disk-failure durability) and the step-by-step restore procedure are
in docs/BACKUP.md.
Winnow ships as an installable PWA, so the culling grid runs full-screen from a home-screen icon on Android/Chrome, iOS/iPadOS (Safari) and desktop Chrome/Edge — no app store.
- Manifest — generated by Next.js at
/manifest.webmanifest(src/app/manifest.ts): standalone display, paper theme/splash colours,start_url=/library, app shortcuts (Sift / Library / Gallery / Import). - Icons —
public/icons:any+maskablePNGs (192/512), a 180pxapple-touch-iconfor iOS, and SVG/favicons. Re-rasterise from the SVG sources withnpx tsx scripts/gen-icons.tsafter editingpublic/icons/icon.svg. - Service worker —
public/sw.js, registered in production only byServiceWorkerRegister. It makes the app installable, serves the build shell stale-while-revalidate, and showspublic/offline.htmlwhen a navigation can't reach the network. It deliberately never caches/apiresponses or media bytes (thumb/proxy/download) — those are large and volatile and always hit the network.
Installability and the service worker require a secure context (HTTPS, or
localhostfor testing). Serve Winnow over TLS behind your reverse proxy to get the install prompt on phones.
To install: Android/desktop Chrome → "Install app" from the address bar / menu. iOS Safari → Share → "Add to Home Screen".
Implemented (MVP): incremental indexing (mtime+size) that skips NAS
sidecar/junk folders (Synology @eaDir thumbnail trees and the #recycle
bin — see isIgnoredEntry, shared by the indexer, the
import feeder and the folder picker), EXIF + hash + dedup,
RAW preview extraction (ARW/DNG…) without demosaicing — except for a RAW that
embeds no preview at all, which LibRaw's dcraw_emu renders at half size —,
a content sniff that sends a JPEG saved under a RAW/HEIF name straight to sharp,
HEIF/HEVC decode
(.heic/.heif/.hif — iPhone & Sony A7C II/Canon — embedded preview first,
otherwise libheif, since sharp's prebuilt libvips only ships the AVIF decoder;
the decoder is lazy-loaded and serialized so it can never crash the worker),
thumb/proxy derivatives
in WebP, mobile-first culling grid, ignore-cascade, media pairing (RAW+JPEG
siblings tied by basename, iPhone Live Photos tied by Apple's Content
Identifier — the pair shows, rates, soft-deletes and exports as one logical
media; a Live Photo plays its .mov motion on hover in the grid, the Sift deck
and its recent strip, and via the on-image LIVE badge / segmented toggle in the
viewer, which also swaps to the RAW source), RAW-copy export + exports
lineage, reclaim space (recycle-bin soft-delete → confirmed purge that frees
the NAS originals + derivatives, with audit + per-file resilience),
multi-feeder ingest (see below), virtualized gallery with
cumulative filters (DB-indexed attributes), calendar view (month grid with
per-day cover + count → drill into a day), map view (plot geotagged media,
select a zone → pick/reject/export the area), pipeline control (pause/resume,
incoming/inbox priority, adjustable scan/analyze rates, real-time counters — see
below), video derivatives (poster + ffmpeg mp4 proxy, optional VAAPI hardware
acceleration), failure list/retry (page /pipeline/failures), scheduled
Postgres backups (compressed pg_dump sidecar + retention + documented
restore, see docs/BACKUP.md), GitHub Actions CI
(typecheck + migrations + build).
Also implemented: ML analysis (faces + text-in-image via the immich-machine-learning container, paced backfill, Faces facet + OCR search — see Faces & text).
Also implemented: Immich push (export target immich: uploads the
keepers to the Immich library through its public REST API, filed in an album,
deduplicated, Live Photos kept live — see
Push to Immich).
Also implemented: People (page /people: the stored face embeddings
are clustered into persons — one stack per person with a cover face, renaming,
merging, and a person gallery filter; cf. lib/people.ts, migration 0035).
V2/V3 (not included): advanced ratings/colors/tags, closed-eyes
detection (needs a facial-landmarks model the ML container doesn't expose),
web export (browser-ready renders, target web), adaptive throttling,
agent-on-NAS, n8n automations.
The specs assume files are already filed on the NAS. Winnow adds an import
stage upstream: all feeders converge to an inbox, then an import worker
verifies (write-then-verify by hash), deduplicates (same content_hash
as the indexer → re-inserting a card duplicates nothing), files into
incoming (NAS archive) following the {device}/{YYYY}/{YYYY-MM-DD}/ template,
then enqueues the usual indexing.
iPhone / Ray-Ban ─┐
SD card (Sony/DJI)─┼─► INBOX ──► Import worker ──► INCOMING (NAS, RW) ──► index → derivatives
Wi-Fi/FTP camera ──┘ (watch) verify+dedup+file {device}/{date}/
Three feeders, all wired to the inbox:
-
Web upload (phone) — Import page in the UI: native file picker, the media are streamed to
POST /api/upload, dropped in the inbox, then imported. No third-party app, works from the phone on the LAN. (HEIC/JPEG/video supported.) -
Offload of a card mounted on the Optiplex —
POST /api/import/offload { path }(or the dedicated field on the Import page). The card is left intact (removeAfter=false). -
SMB / FTP drop — a Samba share and/or an FTP endpoint (optional services in
docker-compose.yml) write into the inbox; a watcher (chokidar,awaitWriteFinishso a transfer in progress isn't imported) enqueues the import automatically. Ideal for the Sony A7C II's FTP transfer.
Guarantees: verified integrity (size + hash of the copy), global dedup
(false partial-hash collisions are caught by a full-content compare, so a
distinct shot is never silently discarded; all hits land in duplicate_hits),
deterministic foldering, per-batch tracking in import_batches (imported /
duplicates / failures). It all reuses the existing indexer, derivatives and dedup.
Inbox vs incoming: the inbox (fast local disk) is a transient staging
area (emptied after import); incoming (NAS, RW) is the permanent filed
archive. They are not duplicates: during import, both copies coexist for the
duration of the verification (write-then-verify), then the inbox is cleaned. Two
hidden subfolders of the inbox (ignored by the watcher and the walk):
.uploads/ (staging for web uploads, imported as a batch — avoids the double
trigger watcher + import) and .failed/ (quarantine for failed files, so
they stop looping; retriable from /failures).
V2/V3 ideas: grouping by "time gap" (gap > N h ⇒ session), configurable
foldering template, n8n trigger on card insertion, full hash (instead of partial)
as an option for strong integrity — note the silent-loss risk of the partial
hash is already mitigated: collisions are verified by a full-content compare and
audited in duplicate_hits, so a full-hash default would be an optimization, not
a correctness fix.