Skip to content

feat: unify face suggestions (#592) + face cleanup (#770) on one verdict layer - #834

Merged
Deeds67 merged 323 commits into
mainfrom
feat/face-review-unified
Aug 12, 2026
Merged

feat: unify face suggestions (#592) + face cleanup (#770) on one verdict layer#834
Deeds67 merged 323 commits into
mainfrom
feat/face-review-unified

Conversation

@Deeds67

@Deeds67 Deeds67 commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

What this does

Ships the fork's whole face-review story on a single shared truth layer, so a decision made in one surface is never re-asked, silently reverted, or left stale by another:

None of these ship alone. This branch supersedes #592 and #770 (leave them open as review context; do not merge them). #838 is already merged into this branch.

Why unify

Both original features independently recorded the same fact — a human's verdict about a (face → person) pairing — in different private tables that neither engine reads. That produced concrete, verified defects when both are present:

# Defect
1 Confirm → re-flag ping-pong. A user confirms a marginal face; the cleanup scan (which reads neither table) proposes moving it away, unrecoverably.
2 Detach → unbounded dead rows. searchFaces filtered asset.deletedAt but not asset_face.deletedAt, so the suggestion scan kept writing rows for "not a face" crops.
3 Cleanup moves never purged suggestions. Held together only by a read filter.
4 Two negative ledgers (person_face_suggestion.rejected vs face_repair_decline), neither read by the other.
5 Scope mismatch — space-person suggestions had no cleanup counterpart.
6 Lock black hole — a face_repair_lock had no revocation path outside the admin page.

The model — three facts, one home each

  • Positive ("a human placed F on P") = face_identity_face.source='manual' — keyed by identity (survives merges), replaced (never stale). face_repair_lock was a weaker duplicate and is retired.
  • Negative ("F is not P") = face_person_verdict, identity-first with a person/space-person fallback, so one rejection answers the question in personal scope and every space. There is no confirmed status.
  • Not-a-face = asset_face.deletedAt (already canonical; searchFaces now honours it).

face_repair_decline is narrowed to console-local cluster mutes. Both engines read one shared exclusion predicate (applyVerdictFilters) through one shared FaceVerdictService — write-time scan exclusion, read-time anti-joins, and cross-scope projection.

Three decisions about what counts as a human attestation (each reversible, each pinned by tests):

  • A people merge preserves each rode-along face's prior source rather than stamping 'manual'. A merge is a person-level decision; mass-stamping blinded the cleanup scan to whole merged clusters.
  • Separating a scoped profile likewise leaves source alone. The identityId rewrite is what makes the separation stick; stamping 'manual' buried every ML mistake inside a contaminated cluster forever.
  • A cleanup move honours lock: false: 'manual' when the caller asks to lock, 'owner-person' otherwise. Writing 'manual' unconditionally made every face in an accepted cluster permanently unreviewable while the response reported locked: 0.

Server

  • face_person_verdict table + repository (identity-first reads, coalescing upsert that never nulls a stronger key, bulk pending-drain), re-keyed through merges (FK identityId is ON DELETE SET NULL — degrade, don't destroy).
  • face_repair_lock table/repository/migrations deleted.
  • Cluster mutes (face_repair_decline) are re-targeted on a merge instead of being CASCADE-deleted with the source person.
  • Both engines exclude locked and non-visible assets, soft-deleted faces, manually-placed faces and negatively-verdicted pairs; unassignFaces clears manual links so "reset all people" leaves no phantom placements.
  • POST /admin/face-repair/unconfirm downgrades a placement so a re-scan may flag it again.
  • Suggestions config: explicit machineLearning.facialRecognition.suggestions.{enabled,maxDistance}, defaulting on, with the legacy suggestionMaxDistance folded in so an admin's existing choice is preserved. The band is derived on upgrade (maxDistance + 0.2, clamped) — the flat default inverted the band for any install that had raised recognition distance, crash-looping config-file installs and 400'ing every admin settings page on DB config.
  • Upgrade path: a one-shot onBootstrap sweep fills the queue for libraries that upgrade into the new default (the ConfigUpdate false→true hook cannot fire for them), marked done only when the sweep actually ran. Per-person scan jobs set removeOnFail, plus a one-time sweep for jobIds already stuck in the failed set.
  • Single-flight scan index rebuilt as a constant-expression unique index (the old UNIQUE (status) let one pending and one running row coexist, and the loser's flagged faces were pruned mid-flight); the migration demotes pre-existing in-flight rows so an already-raced instance can still apply it.
  • Write-time re-checks and transactionality: confirm and both drains run in one transaction each; the lock bucket re-checks placement inside the write transaction (requirePersonId) and counts what was actually written.
  • The four library-sized verdict reads are chunked at 1000 ids, so a high minFaces scan cannot blow Postgres's 65,535 bind-parameter ceiling.
  • Fork isolation: the suggestion engine moved verbatim out of person.service.ts / person.controller.ts into FaceSuggestionService / FaceSuggestionController (person.controller.ts is back to origin/main content); the Redis dangling-active repair moved out of upstream's getJobCounts into getJobCountsWithRepair; FacePersonVerdictRepository sits beside its four siblings instead of inside upstream's alphabetised list; two upstream-owned e2e files reverted.

Web

The admin console is now a coherent surface rather than one page per feature:

  • /admin/face-cleanup — chooser landing with a permanent explanation, equal-weight guided/manual cards, and the full state matrix (never-scanned / running / flagged / zero-flagged / failed); the manual card is genuinely disabled while a scan runs, which is the UI half of the resolveFaces 409 guard.
  • /admin/face-cleanup/scan — two-lane triage: a confident lane you can bulk-approve (with clickable per-cluster spot-check chips) and a review-first lane in a fixed-column table.
  • /admin/face-cleanup/[personId] — guided per-cluster review; /admin/face-cleanup/people[/[personId]] — manual review, any person, no scan required.
  • /admin/face-cleanup/resolutions — negative verdicts from both engines, with a source filter (admin cleanup vs user review), actor and space name, a stated scope, and an empty state that says what the filter is hiding. /declined 307s here.
  • Shared FaceReviewDock + one mode-aware actions-help modal + a face-actions registry that owns each action's colour, icon and copy, so both review pages behave identically; breadcrumbs built from one builder across the whole console.
  • Whole-cluster moves let the admin choose the destination, rendered as identifiable clusters with their own face counts and existence state, with dead controls disabled rather than guarded in the handler.
  • Suggestion banner + review modal on the person page (including space people on the global person page), role-gated actions, real confirm dialogs, honest progress/error reporting, per-user snooze, and no timeline-width stretching.
  • Admin settings gain an explicit face-suggestions toggle.
  • i18n: every new string in all nine fork locales, ICU plurals restored per language's own CLDR categories (18 keys had been flattened, rendering "1 Gesichter"), the {count}NaN bug in destructive confirmations fixed at five call sites, and guards that actually check the fork locales (shared FORK_LOCALES, plural guard, parity guard, placeholder guard scoped off the ~80 translator-owned files).

Review history

This branch has been through three full review-and-remediation rounds since the original write-up, each executed as red-first TDD slices whose tests stay as the regression suite:

Round Spec Scope
2026-07-23 2026-07-23-face-verdict-layer-remediation-design.md 17 defects (D1–D17) in the verdict layer, 10 slices — merge re-keying, actor/identity stamping, the shared verdict service, index drift, owner-only reads, admin thumbnails, modal truthfulness, transactionality, live counts.
2026-07-30 2026-07-30-face-review-unification-remediation-slices.md 35 findings (F1–F35), 14 slices — locked/hidden exclusion, assertions that could not fail, confirm-gate parity, face-level authz, recognition-race durability, merge collisions, resolve atomicity, verdict lifecycle, scan fan-out, API bounds, honest web feedback, migration reconciliation, cross-engine e2e.
2026-08-11 2026-08-11-pr834-ga-readiness-implementation-slices.md (merged as #977) 4 blockers + 6 high-severity defects + mutation-verified test gaps + fork-isolation, 15 slices — everything in the Server/Web sections above marked as an upgrade, ownership, chunking, plural or isolation fix.

Test hardening in the last round was mutation-driven: each gap was confirmed by watching the named mutation survive a green suite, then pinned. Assertions that could never fail (testids present in no component, a spec echoing its own mocked config back at itself) were deleted rather than kept as decoration, and clearMocks is now enabled globally for the web suite.

Testing

  • Server unit + medium, web unit, and Playwright/API e2e all extended per slice; face-review-cross-flow drives both engines against one DB with a real embedding KNN scan.
  • Durability suites for the load-bearing assumptions: face-verdict.merge-durability, face-identity.manual-durability, face-suggestion-exclusions, plus FK-cascade medium tests against a real database.
  • Server unit + medium, web unit, mobile unit, Dart analysis, SQL/schema drift, OpenAPI clients, revert-to-immich.sql and the e2e suites all run in CI on every push to this branch — see the checks on this PR for the current head.

For reviewers

Diff against the union of #592, #770 and #838. The genuinely new surface is the unification and everything after it: face_person_verdict + FaceVerdictService, the lock retirement, the three attestation decisions, the console UX unification, the suggestions-by-default upgrade path, and the fork-isolation refactors. The imported feature commits were reviewed on their own PRs.

Known open items

  • Cross-owner space verdicts (B4). A space Editor's verdict is stamped with a cross-owner identityId, and getPendingForPerson's identity anti-join has no ownership filter — so it can suppress the asset owner's personal queue, and a confirm's clearNegativeForTarget can delete a rejection that owner recorded. The obvious fix (require face ownership) denies the feature's primary flow — shared-space suggestions are cross-owner by design — so it was implemented, reviewed and reverted. Three candidate fixes and their trade-offs are recorded in slice 4 of the GA spec; this needs a product call.
  • Space-confirm transactionality — the D14 transaction wrap covered the personal path only.
  • Dashboard header totals are not recomputed against the shared verdict layer (per-person counts are).
  • Deferred UI: the review page's "skipped: human-confirmed" section (the unconfirm endpoint backing it exists and is tested) and a user-facing undo-my-reject affordance.

⚠️ Deploy note

Migrations on this branch were edited in place across iterations (notably 1787…AddFacePersonVerdict), and #592/#770 recorded migration names that no longer exist on disk. Any RC, staging or personal clone that ran an earlier version of this branch, or #592/#770, must be reset — not upgraded in place (Kysely hard-fails on boot when a recorded migration has no file). Fresh installs and instances coming from main are unaffected.

@github-actions github-actions Bot added documentation Improvements or additions to documentation 🗄️server 🖥️web labels Jul 22, 2026
@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Jul 22, 2026
@Deeds67
Deeds67 force-pushed the feat/face-review-unified branch from 1009885 to c8a4afe Compare July 26, 2026 10:01
@Deeds67 Deeds67 added the rc Auto-build a release-candidate server image and post it on the PR label Jul 26, 2026
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

🧪 Release candidate build

Latest RC pr-834-rc.27 — built from ac99717 · build run

Images published

  • ghcr.io/open-noodle/gallery-server:pr-834-rc.27 (linux/amd64 + linux/arm64)
How to run this RC

In the directory containing your docker-compose.yml, create (or append to) a docker-compose.override.yml:

services:
  immich-server:
    image: ghcr.io/open-noodle/gallery-server:pr-834-rc.27

Then pull and restart:

docker compose pull immich-server
docker compose up -d

Each push publishes a new numbered tag, so update the image line to move to a newer RC. To roll back, point it at an earlier rc.<n> or delete the override and run docker compose up -d again.

Previous builds (19)
Tag Commit Built
pr-834-rc.26 facb904 2026-08-11 18:04 UTC
pr-834-rc.25 dd003c5 2026-08-11 17:53 UTC
pr-834-rc.24 fc09cea 2026-08-10 20:44 UTC
pr-834-rc.23 cf107e9 2026-08-10 20:19 UTC
pr-834-rc.22 ea66f75 2026-08-10 19:16 UTC
pr-834-rc.21 82fb2df 2026-08-10 17:49 UTC
pr-834-rc.20 c8128c5 2026-08-06 09:49 UTC
pr-834-rc.19 fc911ed 2026-08-05 18:06 UTC
pr-834-rc.18 821bbe6 2026-08-02 09:09 UTC
pr-834-rc.17 2f89bc6 2026-07-31 19:12 UTC

9 older build(s) omitted. Their tags are still in GHCR until this PR closes.

Last updated Wed, 12 Aug 2026 16:21:29 GMT — every push while the rc/rc-ml label is set publishes a new numbered RC. All pr-834 RC images are deleted when this PR closes.

@Deeds67
Deeds67 force-pushed the feat/face-review-unified branch 3 times, most recently from 821bbe6 to fc911ed Compare August 5, 2026 17:49
Deeds67 added 18 commits August 10, 2026 19:23
Regenerated TypeScript SDK and Dart client from the three new
face-suggestion endpoints (GET /face-suggestions, POST /confirm,
POST /dismiss). Added SQL query docs for markConfirmed and
markDismissed. Added API e2e spec with 401/400/200/idempotency
coverage for all three endpoints plus the read-gate (hidden person).
- Add PLAYWRIGHT_DB_PORT env var to playwright.config.ts and utils.ts so
  the e2e-web-dev Makefile targets can connect to the dev stack database
  on port 5432 instead of the e2e stack's 5435
- Seed person_face_suggestion rows via raw SQL (no ML required)
- Verify banner renders count, review modal opens and confirms a face,
  and snooze persists the hidden state across reloads
Deeds67 added 17 commits August 10, 2026 19:23
Face suggestions were opt-in, behind a toggle an admin had to find. Default them on
so a fresh install surfaces near-miss faces without any configuration.

The default alone would only reach fresh installs. `onConfigUpdate`'s false -> true
transition — the hook that starts the initial scan — cannot fire for an instance that
upgrades *into* a new default, and `FaceSuggestionMaintenance` has no cron, so an
upgraded library would show the toggle on over a permanently empty queue until someone
renamed a person or ran the job by hand. `PersonService.onBootstrap` therefore queues
one catch-up sweep, guarded by a system-metadata marker so it runs at most once per
instance (the same one-shot pattern as SharedSpaceService.onBootstrap). The marker is
burnt even when an admin has the feature switched off, since a later opt-in is already
served by the ConfigUpdate transition.

The sweep is safe to re-run: upsertPending is a conditional upsert that cannot resurrect
a resolved row, and the scan excludes manually-placed and negatively-verdicted faces
before writing, so an instance that was already using suggestions gets no duplicates and
no re-asked questions.

Existing configuration is preserved by the legacy fold: an admin who had explicitly set
a suggestion distance keeps that decision, including one too low to ever match, which
still reads as off. Only instances that never configured the setting pick up the new
default.

Docs: rewrite the Face Suggestions section around the new default, document the upgrade
sweep, and add a section on what turning the feature off and back on does (nothing is
deleted; pending rows are re-validated on read, decisions are durable).

The shared-space spec gains a `getById` default: with suggestions on, the rename and
metadata-backfill paths now get past `areSpacePersonSuggestionsEnabled` and ask whether
the space has face recognition enabled.
The page still described the pre-unification console. Corrected against the current UI:

- the chooser's mode is "Guided cleanup", not "Guided review"
- the scan page's two lanes are "Ready to auto-fix" (Approve all / Approve / Review them
  / Exclude from this batch) and "Needs your review" — the old "Confident — auto-selected"
  pre-selection and the "Re-attribute selected (N)" batch button are gone, and the lanes
  are applied independently
- the review page's per-face verdicts were undocumented; added the six actions with the
  Keep here vs. Confirm & lock distinction, which is the one worth learning
- "View declined" no longer exists. Decisions are logged on the Resolutions page, which
  also covers the face suggestions users review on their own People pages, filterable by
  source, with Undo per entry
- manual review's action label is "Confirm & lock", and the reason badge is "also
  flagged", not "bad-target"
Separating a profile no longer marks its faces human-confirmed.
detachScopedProfile stamped source='manual' on every backing face. Under
the unified verdict layer that means "a human attested to this face" and
excludes it, owner-agnostically, from both the cleanup console and the
suggestion scan — permanently. Separating a contaminated cluster therefore
buried every ML mistake inside it, with no UI to undo. Separation is a
grouping decision, not a per-face attestation, so the source label is left
alone; the identityId rewrite is what makes the separation stick.

The one-shot suggestion sweep marker now records that a sweep ran, not that
one was queued. It was burnt at queue time even when the feature resolved
off, on the assumption that a later opt-in re-triggers via ConfigUpdate's
false -> true transition. That cannot happen under IMMICH_CONFIG_FILE, where
updateSystemConfig throws and a YAML edit plus restart emits only ConfigInit
— leaving an admin with a toggle reading "on" over a queue nothing fills.
The same write also outlived a failed sweep, which runs attempts:1 with
removeOnFail:true. Writing it from handleFaceSuggestionMaintenance's success
path covers both, and stops the ConfigUpdate opt-in from queueing a
redundant second sweep on the next boot.

The resolutions page reported both target states backwards. person.name and
shared_space_person.name are NOT NULL DEFAULT '', and both target FKs are
ON DELETE SET NULL, so a live unnamed cluster arrives as an empty string
beside an intact id, and a deleted target as a null id. Branching on the
name first labelled the live cluster "Deleted target" and the genuinely
deleted target "Unnamed cluster". Branch on the id instead. The spec pinned
the inversion with a personName:null fixture the SQL cannot emit.
@Deeds67
Deeds67 force-pushed the feat/face-review-unified branch from c8128c5 to 1e27f03 Compare August 10, 2026 17:24
Nine user-facing keys added by this branch shipped in en.json only, so the
face-cleanup confirm dialog, the resolutions filter/pagination empty states,
the deleted-target label and the two partial-load error banners fell back to
English in every locale.

face_cleanup_resolutions_load_more reuses the already-translated
face_cleanup_review_load_more values verbatim — the two English strings are
identical. The rest follow each file's existing face_cleanup vocabulary
(cluster -> Cluster/groupe/gruppo/grupa/grupo/группа/聚类/叢集, target ->
Ziel/destination/destinazione/bestemming/cel/destino/целевой человек) and
register, with Polish and Russian carrying the few/many plural categories the
neighbouring count strings already use.
…line width

The banner is a block-level flex column, so it spanned the whole timeline
regardless of content. Its content is at most five 56px face crops and two
small buttons — roughly 400px — which left a metre of empty panel on a
desktop screen with "N faces found" stranded against the far edge by the
header's justify-between.

w-fit sizes the card to its content instead. The only row that can grow is
the title, so it keeps two caps: max-w-full holds it inside a narrow
viewport, where the title's existing min-w-0 + truncate then engage, and
sm:max-w-2xl stops a very long person name from dragging the card back
across a wide screen.

No behaviour change and no change at mobile widths, where the card already
filled the available space.
An instance that has never scanned was shown a primary button labelled
"Re-scan" wearing a refresh icon, and an empty state reading "Click Re-scan
to detect contaminated face clusters" — pointing at a control in the
opposite corner from the sentence naming it. The chooser one click earlier
already says "Run first scan", so the wording regressed mid-flow.

scan === null is exactly "this instance has never scanned" (loading and
loadError are separate branches), so the action now names itself for what it
is, reusing the chooser's own face_cleanup_mode_run_first_scan so the button
an admin was just told to click is called the same thing on both pages, with
a radar icon instead of the refresh arrow. The CTA also appears inside the
empty state, so the instruction and the action are one object.

The empty-state copy no longer names the button at all — naming a control in
body copy is what let it rot, and all nine locales had translated the stale
name into their own copy. It now describes what a scan does and that it
changes nothing, reusing each file's existing face_cleanup_intro_scan_body
vocabulary and register. The i18n coverage spec gains a guard, in the same
shape as its other I7 checks, asserting no locale names the re-scan button
in that copy again.

Advanced (maxDistance / minFaces / maxFlaggedFraction) drops to ghost before
the first scan: the defaults are what run one wants and an admin who has
never seen a scan's output has no basis for tuning them. Recessed, not
hidden, so it stays reachable.

The existing "clicking Re-scan" test mocked getLatestScan to null while
asserting the re-scan label — the precise state this fixes — so it now seeds
a completed scan. Its timeout was also cascading into the dismiss test that
follows, whose queued mockResolvedValueOnce values the failed test's
un-unmounted page was consuming.
…confirm race

Two web e2e failures, only one of them caused by the first-run change.

face-cleanup.e2e-spec.ts asserted a "Re-scan" button on a stack that has
never run a scan — the same mistake the unit test made, and precisely the
wording this branch fixes. It was asserting the bug. It now expects the
first-run action, asserts "Re-scan" is absent, and covers the CTA that now
lives inside the empty state. The file header's summary is corrected to
match.

person-face-suggestions.e2e-spec.ts is unrelated and was a latent race: the
confirm click fires a POST it does not await, and the only statement between
the click and the API read asserted that suggestion-progress was visible —
which it already was before the click, so it passes instantly and
synchronises nothing. The API read could therefore beat the write and see a
still-pending total of 3. It now arms a waitForResponse on the confirm POST
before clicking (album.e2e-spec.ts's idiom); the pending drain shares the
confirm's transaction, so the response is a sufficient gate.
…ces claiming a filter it never had

Two things an admin had no way to learn from the UI.

Resolutions lists NEGATIVE verdicts only, and the sole writer of a
cleanup-sourced one is the "keep here" bucket — cleaning a person up by
moving or confirming faces records nothing here at all. That scope lived
only in a source comment, so an admin who had just processed several
people arrived to an empty list that read as lost work. A subtitle now
says it on the page, outside every loading/empty/error branch, because
the empty states are exactly when it is needed.

"Minimum faces per person / Skip people with fewer faces than this" was
plainly false, and all nine locales plus the docs had faithfully
translated it. No per-person face-count filter exists anywhere in the
scan pipeline. decideReattribution uses minFaces to require that a
suspected owner hold at least that many of the face's near neighbours,
and to treat a person holding fewer than that as not claiming its own
faces — which flags them without their having to lose the vote. Raising
the value therefore flags MORE small clusters, not fewer, which is the
opposite of what the control promised.
…ng, instead of just that nothing matched

"No decisions match this filter" is true but useless: it reads as lost
history, which is precisely the wrong conclusion when the rows are
sitting one chip away. The state now names the source that is empty,
counts what the filter is hiding, and offers one click back to all
sources.

The precise wording is gated on everything being loaded. `filtered`
derives from `resolutions`, which holds only the pages fetched so far,
so "no admin cleanup decisions yet" is a claim about the whole list that
only the fully-loaded case can support — with pages outstanding, a match
may simply not have been fetched. That case keeps the neutral wording
and grows its own Load more button, which otherwise lives in the rows
branch and is unreachable exactly when the filter matches nothing.
# Conflicts:
#	server/src/repositories/face-identity.repository.ts
…#977)

* docs(face-review): spec the GA-readiness fixes from the #834 correctness review

* docs(face-review): correct six factual errors in the GA-readiness spec and add BDD conventions

* fix(face-suggestions): derive the suggestion band so upgraded installs still boot

An install that raised facialRecognition.maxDistance before `suggestions`
existed carries no suggestions block, so the merge supplied the 0.7 default —
inverting the band against any recognition distance >= 0.7. In config-file mode
buildConfig threw and the server crash-looped on upgrade; on a database source
every settings page 400'd, because the whole config is PUT on each save.

deriveSuggestionBand raises an UNSET band to maxDistance + 0.2 (clamped to the
schema ceiling of 2, disabling the feature if no valid band exists). A band the
admin set explicitly is left alone, so a real misconfiguration still surfaces.

* fix(face-cleanup): honour lock:false instead of permanently locking every moved face

executeRepair wrote source='manual' for every move. A manual identity link is
the strongest verdict in the system — getManualLinkedFaceIds and the pending
eligibility anti-join both exclude such a face from every scan and every
suggestion queue, permanently. So accepting a scan suggestion on a large
cluster made every one of those faces unreviewable forever, while the response
reported locked: 0 and the DTO promised 'plain moves stay undurable unless the
caller opts in'.

The relink still always happens — leaving the face on the destination while it
carries the source person's identity is the torn state FaceIdentityBackfill
silently reverts. Only the strength is now conditional: 'manual' when the
caller asked to lock, 'owner-person' otherwise. Route keys include the flag so
a mixed batch cannot collapse into one write. entireCluster carries no lock
field by design and keeps today's durable default.

Four medium tests asserted the old behaviour, one of them named 'records a
human placement for a moved face even when lock: false' with a comment stating
the flag no longer changes durability. That was a deliberate decision that
contradicted the DTO and the picker's lock checkbox; this commit resolves the
contradiction the other way and the tests now pin the new behaviour, with a
lock:true positive control alongside.

* fix(face-cleanup): pass raw counts to ICU plurals so large clusters stop rendering NaN

Five call sites passed .toLocaleString() into a {count, plural} argument. ICU
computes # as value - offset, so "2,952" - 0 is NaN: the whole-cluster confirm
read "This moves all NaN faces to Anna" and its button "Move all NaN". Only
above a thousand, which is exactly where a whole-cluster move is least
reversible.

Adds a render test at 2952 with a discriminating control (the formatted string
still yields NaN) plus a source scan so a new call site cannot reintroduce it.
One existing test asserted values.count === '5' as a string, which was only
true because of the formatting; it now pins the number.

* fix(spaces): require face ownership before a space verdict can suppress another user's queue

Every verdict row is stamped with the target's identity, and face_identity.id is
a cross-owner key whose anti-join in getPendingForPerson has no ownership
filter. So a row written against a face the caller does not own suppresses the
ASSET OWNER's personal suggestion queue for their own face — and a confirm also
runs clearNegativeForTarget, whose identityId arm can delete a rejection that
owner recorded, leaving no trace it existed.

The personal twin has guarded this since S11, with a comment naming the exact
hazard. Its space counterpart checked only Editor role and reachability. Space
membership grants rights inside the space; it does not grant the right to
record a durable verdict about another user's face.

Guard sits before the reachability check so an unowned face 400s rather than
returning a silent false. dismiss delegates to reject and inherits it; both are
pinned, alongside a positive control that the same call succeeds when the
editor does own the face.

* fix(face-cleanup): re-check face ownership inside the lock transaction

The lock bucket's eligibility read (getEligibleFaceIdsForPerson) runs
outside the write transaction and its own comment calls itself
"advisory only". reattributeFaces and detachFaces both re-check
placement at write time; replaceFaceIdentities did not, so a
concurrent reassign landing between the read and the write could leave
asset_face.personId pointing at the new person while this call still
re-pointed the old person's identity onto the face.

replaceFaceIdentities now accepts an optional requirePersonId: when
set, each chunk is filtered (inside the caller's transaction) to faces
still assigned to that person before writing, and returns the
asset-face ids actually written. The lock bucket passes
requirePersonId, scopes drainPendingForFaces/clearNegativeForTarget to
the written ids, and counts locked from what was actually written
rather than what was requested. The move path (executeRepair) keeps
calling replaceFaceIdentities without requirePersonId, since
reattributeFaces has already re-pointed personId inside the same
transaction by the time it runs.

Added medium coverage for the guard: a raced face is excluded from the
write and the returned ids, a face still on the required person writes
normally, requirePersonId omitted keeps writing unconditionally, and
an empty input returns []. Updated two pre-existing F12/F13 tests
whose duplicate-lock-id assertions hard-coded the old
count-what-was-requested semantics for `locked`.

* Revert "fix(spaces): require face ownership before a space verdict can suppress another user's queue"

This reverts commit b51afc0.

* docs(face-review): record why the B4 guard was reverted and what must be decided

The space face-suggestion flow is cross-owner by design — the canonical
fixture has an Editor acting on a third user's asset, and the pending read
deliberately spans cross-owner contributions. Requiring face ownership denied
the feature's primary flow and shipped a red medium suite.

The underlying leak (a non-owner's space verdict suppressing the asset owner's
personal queue via the cross-owner identity arm, and a confirm deleting that
owner's rejection) is still open. Slice 4 now records the three candidate
fixes and their trade-offs instead of a fix that cannot ship.

* fix(face-cleanup): chunk the four library-sized verdict reads

face-verdict.service.ts fans out to four reads that bind one parameter
per id and were never chunked: getManualLinkedFaceIds and
getPersonVerdictTokens (face-identity.repository.ts),
getNegativeVerdictTokens (face-person-verdict.repository.ts), and
getClusterMuteMap (face-repair-decline.repository.ts). minFaces is
admin-settable, so a full-library scan can pass every flagged face (or
suspected owner) in the instance and exceed Postgres's 65,535
bind-parameter ceiling, permanently failing scans at high minFaces.

Three of the four return a Map, which the existing @ChunkedArray/
@ChunkedSet decorators cannot merge, so all four are chunked with an
explicit loop of 1000 instead, matching every sibling write path in
this feature. No decorator changed, so the generated
server/src/queries/*.sql docs are unchanged.

* fix(face-review): close the review findings on slices 1-3

Slice 1 (B1):
- Add the equality-boundary case. An install sitting exactly at the 0.7 default
  band is the most common upgrade shape, and mutating the guard from > to >=
  left the whole file green while reintroducing the crash for those users.
- Round the derived value to two decimals: 0.7 + 0.2 is 0.8999999999999999,
  and updateConfig persists it and the admin number input renders it.
- Warn when the ceiling forces suggestions off. The invariant check in
  buildConfig is gated on suggestions.enabled, so it cannot fire afterwards —
  without this the admin gets no diagnostic at all.
- Document the derivation in the admin docs (the slice's own step 5, missed).

Slice 3 (B3):
- Tighten the source guard from toLocaleString\(\) to toLocaleString\(.
  A locale-aware call such as toLocaleString('de-DE') formats identically and
  evaded the narrower pattern; verified by mutation that it now fails.

Slice 2 (B2):
- Correct two comments that contradicted the code after the behaviour change.

* fix(face-cleanup): make the in-flight scan index enforce a single scan

face_repair_scan_in_flight_uq was UNIQUE (status) WHERE status IN
('pending','running'), which is unique on the VALUE of status — so
one 'pending' row and one 'running' row could coexist. Two admins
crossing the pending -> running transition at the same time both
succeeded, and pruneSupersededScans then deleted the loser's
flagged-face rows mid-flight.

Fixed with a constant-expression unique index (ON (true) WHERE ...)
so every in-flight row evaluates to the same key and at most one can
exist regardless of which in-flight status it carries. The migration
first demotes every in-flight row but the newest to 'failed', so an
instance that already hit the old race can still apply this migration
instead of crash-looping on index creation. Table decorator and
migration_overrides payload are updated to match (schema-drift.spec.ts
stays green), and the new migration name is appended to
revert-to-immich.sql.

* fix(face-suggestions): let a failed per-person scan be re-enqueued

PersonSuggestionScan and SpacePersonSuggestionScan set removeOnComplete
but not removeOnFail, unlike every sibling job option in
getJobOptions. A job that fails while removeOnFail is unset
permanently occupies its stable per-person dedup jobId, and BullMQ
silently ignores every later add() with the same id — one transient
failure and that person's suggestion queue never refills again, with
no log and no admin-visible symptom.

removeOnFail protects new failures only; it does nothing for jobIds
already stuck in the failed set. The existing prefix sweep
(removeFailedJobsByJobIdPrefix) has one production caller,
SharedSpaceService.onBootstrap, gated by
SystemMetadataKey.SharedSpaceFaceJobCleanupState — already true on
every instance that has ever booted, so folding the new prefixes into
that gate would make the sweep for them a permanent no-op exactly
where it is needed. Added a second, independent one-time sweep under
its own SystemMetadataKey.PersonSuggestionScanJobCleanupState so it
actually runs and clears the already-stuck jobIds.

* fix(i18n): restore ICU plurals in nine locales and make the guard check them

18 count-bearing admin.face_cleanup_* keys had been flattened to a bare
{count} interpolation in de/fr/it/nl/pl/es/ru/zh_Hans/zh_Hant, so they
rendered ungrammatical text like "1 Gesichter" / "1 visages" / "1 лиц" for
an admin whose queue was down to exactly one item. The guard written to
catch this (face-cleanup-plurals.spec.ts) only ever registered `en` and
read en.json, so it stayed green while the bug shipped in all nine
translations.

- Extract the fork's nine-locale list into web/src/lib/i18n/fork-locales.ts
  (FORK_LOCALES) and have fork-string-parity.spec.ts and
  face-cleanup-i18n-coverage.spec.ts import it instead of each keeping
  their own copy, which could silently drift apart.
- face-cleanup-plurals.spec.ts now derives the plural-bearing key list from
  en.json (any admin.face_cleanup_* whose English value carries
  `{count, plural`) and asserts every fork locale's translation of it also
  carries an ICU plural clause. A key missing entirely from a locale falls
  back to English and is fork-string-parity's concern, not this guard's.
- zh_Hans / zh_Hant are not valid BCP47 tags: constructing IntlMessageFormat
  with the underscore code throws, and svelte-i18n silently falls back to
  the raw, unparsed message — so a render assertion registered under
  'zh_Hans' would pass no matter what the translated content says. The
  locales are now registered under their hyphenated convertBCP47 form
  (exactly how the app's own initLanguage does it) while still reading the
  underscore-named source files, with tests proving that registration is
  load-bearing rather than decorative.
- Rewrote the 18 keys in all nine locales using each language's own CLDR
  plural categories rather than a mechanical copy of English's two
  branches: one/other for de/es/fr/it/nl, one/few/many/other for pl/ru,
  and other-only for zh_Hans/zh_Hant (Chinese has no plural inflection).
- Fixed i18n/nl.json's face_cleanup_review_picker_create: a bare apostrophe
  before `{query}` opens an ICU quoted-literal section, so the placeholder
  printed literally instead of being substituted. Replaced the apostrophes
  with a plain double quote (matching English's own `"{query}"`), which is
  not ICU-significant, and added a render assertion plus a positive
  control proving the old shape really did leak the placeholder.

No apostrophe guard was added for the other locales — a naive /'\{/ regex
produces false positives across existing correct ICU escapes (e.g. oauth
and delete_group keys), and this was the only genuine occurrence.

* fix(face-cleanup): keep cluster mutes when their person is merged away

face_repair_decline.personId is ON DELETE CASCADE (unlike
face_person_verdict.personId, which is SET NULL and already re-pointed by
retargetVerdictPersonId), so mergePersonProfile deleting the source person
silently destroyed any cluster mute recorded against it. The console's
"stop showing me this cluster" decision vanished and the cluster
resurfaced on the very next scan, even though
face-repair.merge-consistency.spec.ts's own header claims both durable
Face Cleanup facts survive a merge — they didn't, this one just had no
test.

Added retargetDeclinePersonId (server/src/utils/face-decline-merge.ts,
mirroring face-verdict-merge.ts's style) and call it in mergePersonProfile
immediately after retargetVerdictPersonId, so it commits or rolls back
with the rest of the merge. A person carries at most one type='person'
decline row (createClusterMutes deletes-then-inserts), so there are three
cases: only the source has a mute (move it), only the survivor has one (or
neither does — no-op), or both have one, in which case the survivor's row
absorbs the union of both suspected-owner sets and the source row is
dropped. Leaving two rows for the survivor would be worse than the bug:
getClusterMuteMap does a plain Map.set() per row it reads, so which row
"wins" would depend on read order.

Edge case: suspectedOwnerIds carries no FK, so if the survivor's mute
already lists the source person as a suspected owner, the union keeps that
now-dangling id. Left as-is — nothing upstream of this function enforces
referential integrity on that column either, and pruning it is out of
scope for a merge-time re-pointing step.

* test(face-review): pin the seven assertions that survived mutation

All seven gaps were confirmed by watching the named mutation survive the
existing suite (green), then adding the assertion and watching it fail (red),
then reverting (green again):

- person.service.ts confirmFaceSuggestion: deleting the face-ownership
  requireAccess call (Permission.PersonCreate) survived unit tests.
- person.service.ts handlePersonSuggestionScan: removing
  `visibility: spaceVisibleAssetVisibilities` from the personal searchFaces
  call survived (would let Locked/Hidden assets become suggestable). The
  space-scan call's copy of the same arg is confirmed redundant by the search
  repository's unconditional spaceVisibilityGate on that branch, so it is
  deliberately left unpinned.
- identityId was expect.any(String) at every verdict-write assertion in
  person.service.spec.ts and shared-space.service.spec.ts. Pinned to the
  concrete id the mocked ensure*Identity call returns, and added an
  argument-pin on the shared-space reject/ignore call site (the OTHER
  ensureSpacePersonIdentity call site, inside confirm, was already pinned).
- clearNegativeForTarget: all 6 production call sites (3 in person.service.ts,
  1 in shared-space.service.ts, 2 in face-repair.service.ts) were deletable
  with zero failures. Added an argument assertion at each, matching that
  site's real target shape (personId vs spacePersonId).
- face-repair.service.ts executeRepair: the C6 cross-owner guard could never
  fire because every existing test resolves both people to the same owner.
  Added arrangeDifferentOwnerMove and a test that gives getById differing
  ownerIds per id, asserting the route is skipped and nothing is written.
- face-repair.service.ts triggerScan: hardcoding all seven scan-override
  params to their defaults survived — the Advanced Scan modal was decorative.
  Added an assertion pinning all seven fields to non-default values.
- Deleted two tests that could never fail: the "admin guard" test in
  face-repair.scan.spec.ts asserted only that a QueueName constant is
  defined (the real guard is already proven by
  face-repair-admin.controller.spec.ts's authenticated-route tests), and
  face-repair.scan-defaults.spec.ts, which echoes its own mocked config
  values back at itself — verified vacuous by hardcoding the service to
  ignore its config input entirely and watching the test still pass.

* test(face-review): clear mocks, fix vacuous assertions, cover the deletion cascades

- web/vite.config.ts: enable clearMocks: true globally. Ran the whole web unit
  suite twice after enabling it (5255/5255, identical to the pre-change
  baseline both times) — nothing relied on cross-test mock-call leakage, so
  no fixes were needed.
- Three testid assertions targeted testids that exist in no component
  (locks-section, manual-review-load-more, move-rest-selection-btn) and so
  could never fail. Deleted each: none has a real counterpart to point at
  (resolutions has no locks concept, the manual-review page uses a scroll
  sentinel with no load-more button, and the rest section was deliberately
  built with no separate commit button — its real guard,
  `resolveFaces).not.toHaveBeenCalled()`, already covers the behaviour).
  Per the brief's correction, no generic testid-guard test was added.
- scan/+page.svelte's polling test never advanced past the completed-scan
  poll. Rewrote it to advance to the poll that returns a completed scan, then
  advance another 20s (past POLL_MAX_MS) and assert getLatestScan's call
  count is unchanged — this fails if stopPolling() is skipped in the
  !isActive branch.
- [personId]/page.spec.ts only ever asserted `goto` was NOT called. Added a
  positive assertion on the Apply-success path (goto called with
  Route.faceCleanupScan()) and on the empty-state Cancel button (the other
  goto call site on this page) — both currently unexercised. The manual
  sibling (people/[personId]) already had positive coverage for its own
  target, Route.faceCleanupPeople(); left as-is.
- Added page-load.spec.ts for all 7 face-cleanup +page.ts files (the repo's
  existing convention), covering the authenticate(url, { admin: true }) gate
  on each and the declined/+page.ts 307 redirect to the unified resolutions
  page.
- Added medium tests for three FK-cascade paths nothing exercised against a
  real database: deleting an asset_face cascades its face_person_verdict row
  (ON DELETE CASCADE); deleting the actor user degrades actorId to NULL (and
  a scan's requestedBy the same way) while listNegativeVerdicts still renders
  the row via its LEFT JOIN; deleting a shared space cascades its
  shared_space_person row, which degrades the verdict's spacePersonId to
  NULL while identityId (an independent FK straight to face_identity)
  survives untouched.

Full web unit suite green (347 files / 5269 tests) with check:svelte and
check:typescript clean. Server: ran the full medium suite twice; the
resulting failures are pre-existing, environmental (Postgres connection
exhaustion under full-suite parallelism — a different set fails each run)
and reproduce on files this commit never touches; every touched/added medium
file passes cleanly in isolation.

* refactor(face-suggestions): move the suggestion engine into fork-owned files

person.service.ts carried the fork's entire face-suggestion engine inline —
the four suggestion-scan job handlers and the five confirm/reject/ignore/
dismiss/get endpoints — none of which interleave with upstream PersonService
logic. Moves that code, verbatim, into a new FaceSuggestionService and
FaceSuggestionController, matching the isolation already applied to
face-repair.service.ts, classification.service.ts and shared-space.service.ts.
person.controller.ts returns to its origin/main content; person.service.ts
keeps only the genuine in-place hooks (verdict clearing in reassignFaces/
reassignFacesById, the re-scan queue in update(), the backfill-completion
queue, and the bootstrap sweep).

Medium tests that exercised the moved methods through PersonService now use
a second service instance sharing the same MediumTestContext dependencies
(MediumTestContext.getService), added to test/medium.factory.ts for this
purpose.

Behaviour is unchanged: every route path, DTO and job name is identical, the
unit suite's pass count is identical before and after (5655 passed / 14
skipped), and rebuilding + regenerating the OpenAPI spec and both generated
clients produces a zero diff in open-api/, packages/sdk/ and mobile/openapi/.

* refactor(jobs): take the Redis repair out of upstream's getJobCounts

getJobCounts is a pure delegate upstream. The fork made it async and
prepended removeDanglingActiveJobs (cleaning up BullMQ "active" list
entries with no backing job hash or lock, first found on the fork's own
PeopleBackfill queue) directly inside it, so every read — including
upstream's own call paths (queue.service.ts's admin queue poll,
media.service.ts, person.service.ts's recognition-queue checks,
storage-migration.service.ts, and this repository's own getTelemetryMetrics)
— silently mutated Redis.

Adds getJobCountsWithRepair(name), carrying today's repair+delegate body,
and restores getJobCounts to upstream's exact pure form. Every caller listed
above turned out to be either byte-identical to origin/main or, in
getTelemetryMetrics's case, a passive per-request-shaped telemetry read
that should never write on a read either way — verified individually
against origin/main rather than taken on the original spec's word, which
mislabeled several of them as fork-added. The one caller that genuinely
needs the repair is waitForQueueCompletion: it blocks in a loop deciding
whether a queue has actually drained, and a dangling active entry would
otherwise read as perpetually "still active" — a comparatively rare,
blocking wait, not a per-request read, so paying for the repair there does
not reintroduce the problem.

Adds a test asserting getJobCounts performs no Redis writes even with a
dangling active entry present, and renames the existing repair test to
target getJobCountsWithRepair directly.

* refactor(fork): three isolation fixes for FacePersonVerdictRepository, placeholders.spec.ts and e2e utils

15a. FacePersonVerdictRepository sat inside upstream's alphabetized repository
list in base.service.ts (BASE_SERVICE_DEPENDENCIES, the constructor parameter
list, and the static create() ctx list) instead of beside its four siblings
(FaceIdentityRepository, FaceRepairRepository, FaceRepairScanRepository,
FaceRepairDeclineRepository). An upstream insertion into that stretch would
conflict on every rebase, and a careless resolve would silently desync the
positional lists and inject the wrong repository into every service. Moved
it beside its siblings in all four affected files (base.service.ts x3,
repositories/index.ts, test/utils.ts x3, test/medium.factory.ts x2) — the
full unit suite passing is the proof the positional lists still agree.

15b. web/src/lib/i18n/placeholders.spec.ts iterated every file in i18n/,
including the ~80 translator-owned locale files this fork does not
maintain — which is why mr.json and ms.json had been hand-patched to keep
the suite green. Scoped it to en.json + FORK_LOCALES (the shared module
fork-locales.ts already created for this exact purpose) and reverted the
mr.json/ms.json patches now that Weblate content is out of scope. The
module's own doc comment already claimed this suite was scoped correctly;
it wasn't — now it is.

15c. Reverted two upstream-owned e2e files to origin/main:
asset.e2e-spec.ts (an upstream live-photo test edited to load a fork video
fixture from across the server/e2e directory boundary — no independent
justification for the real-video requirement was found, so it reverts to
upstream's makeRandomImage() bytes) and utils.ts's isQueueEmpty, whose
paused-aware semantics change affects all ~97 callers of the shared
waitForQueueFinish helper, including upstream's own jobs.e2e-spec.ts (which
deliberately waits on a paused queue). No currently-existing fork spec was
found to depend on the changed semantics at its actual call site, so no
fork-local isQueueEmptyIgnoringPaused was added — the brief's own
instruction gates that addition on such a spec existing. utils.ts's DB URL
change to use the fork-added playwrightDbPort export was kept; it isn't
part of the isQueueEmpty regression and playwrightDbPort has no upstream
equivalent to fall back to.

Verified with `cd server && pnpm test` (identical pass/fail shape, only the
expected +1 from slice 14's new test), `cd web && pnpm exec vitest run`,
`cd web && pnpm run check:svelte && pnpm run check:typescript`, and
`cd e2e && pnpm exec tsc --noEmit` (e2e suites intentionally not run — they
need a live stack).

* test(spaces): account for the new person-suggestion-scan sweep on first bootstrap

H8 split onBootstrap into two independent cleanups, each behind its own state
key — the shared-space sweep is already marked done on every booted instance,
so appending prefixes to it would never have run. That makes three
removeFailedJobsByJobIdPrefix calls on a first boot (PeopleBackfill and
FacialRecognition for shared-space faces, PeopleBackfill for the suggestion
scans), not two.

The test's actual intent — sweep on the first bootstrap only — is unchanged and
still asserted: the second boot calls nothing, because both state keys are set.
Also pins the new sweep's arguments so the prefixes cannot silently drift.
@Deeds67
Deeds67 merged commit 76b9842 into main Aug 12, 2026
71 checks passed
Deeds67 added a commit that referenced this pull request Aug 12, 2026
…p collision

#834 landed 1785000000000-AddFaceRepairLock, which shares a timestamp prefix
with this branch's 1785000000000-AddPhysicalUsageInBytes. Two migrations on one
timestamp is ambiguous and breaks the repo's unique-round-timestamp convention,
so move ours to 1791000000000 — above main's highest (1790000000000).

The migration has never merged, so no database has recorded the old name and no
pre-rename alias entry is needed in the revert script.
Deeds67 added a commit that referenced this pull request Aug 12, 2026
…o upstream semantics (#979)

* feat(server): add storageUsage config group for derivative accounting

* feat(server): add derivative filename asset-id parser

* fix(server): remove dash from derivative filename separator check and add android motion test

* feat(server): allow filtering files out of storage size walks

* feat(server): add physicalUsageInBytes column and reset quota to upstream semantics

Adds user.physicalUsageInBytes (originals + thumbnails + transcodes) and a
migration that preserves the current quotaUsageInBytes value into it before
resetting quotaUsageInBytes to upstream semantics (asset_exif.fileSizeInByte
sum, external libraries excluded). Also updates the test factory for the new
column and keeps revert-to-immich.sql / its coverage test in sync with the
new fork migration and column.

* feat(server): look up a user's external library asset ids

* fix(server): restore upstream quota usage and track physical usage separately

* docs(plan): add task 12 for config-file support and documentation

* fix(server): pin the storage-usage resync to the microservices worker

* feat(server): enforce quota against physical usage when opted in

* feat(api): expose physical usage and the display toggle

Adds physicalUsageInBytes to UserAdminResponseDto and
storageUsageIncludesDerivatives to ServerConfigDto so clients can pick
which storage-usage number to show, without exposing the separate
quota-enforcement toggle.

* feat(web): show physical storage usage when the server opts in

* style(web): run prettier on rail-storage.spec.ts

* fix(web): add physicalUsageInBytes to userAdminFactory to green the typecheck

* test(web): describe wiring divergence in the parity comment; cover null physical usage

* feat(web): add admin settings for derivative storage accounting

* fix(i18n): normalize storage terminology within the storage-usage panel

Polish used three different storage nouns (magazynu / no noun / przestrzeni
dyskowej) across the panel's four switch strings; normalized to magazyn
throughout, keeping kontyngent for the quota concept itself. Italian's
display-switch description said spazio di archiviazione while its own
title said utilizzo visualizzato; changed the description to echo utilizzo
di archiviazione, matching the title and the panel's other keys.

* chore(fork): declare storage usage accounting in the ownership manifest

* docs: document storageUsage config file options

* docs: correct Task 12's rationale for how storageUsage is pinned

* chore(server): sync the generated SQL for getExternalAssetIds

The @GenerateSql decorator on AssetRepository.getExternalAssetIds emits a
block that was never written to src/queries/asset.repository.sql, so the
sql-schema-up-to-date job would fail on the diff.

* chore(api): regenerate the Dart client for the storage usage options

Picks up ServerConfigDto.storageUsageIncludesDerivatives,
SystemConfigDto.storageUsage, UserAdminResponseDto.physicalUsageInBytes and
the new SystemConfigStorageUsageDto.

* test(e2e): assert the new server config flag in the exact-match literal

GET /server/config now returns the required storageUsageIncludesDerivatives,
so the toEqual literal has to carry it. With both storage usage toggles off
the exposed value is false.

* fix(server,web): keep the displayed storage figure consistent with enforcement

Two independent toggles meant a supported configuration
(includeDerivativesInDisplay=false, includeDerivativesInQuota=true) showed a
user "10 GB of 20 GB" while rejecting their upload as over quota — the exact
misleading number this work set out to remove.

- server: expose storageUsageIncludesDerivatives as the OR of the two
  toggles, so opting quota in implies showing the enforced figure.
- web: make the admin user page's storage meter follow the same server
  config flag instead of always reading quotaUsageInBytes, so an admin
  investigating a rejected upload sees the number enforcement used.

* fix(server): resync cached storage usage at boot on config file installs

SystemConfigService.updateSystemConfig rejects outright while
IMMICH_CONFIG_FILE is set, so those installs never emit ConfigUpdate and the
only resync trigger never fired. An admin who set includeDerivativesInDisplay
in a config file saw 0 B on a fresh install, or a drifted migration-time
value on an existing one, until the nightly job ran.

Resync on ConfigInit instead, gated on the install actually using a config
file and on at least one toggle being on. Pinned to the microservices worker
like onConfigUpdate, since UserSyncUsage carries no jobId.

Also documents how the figure populates and how it stays fresh, including
that nightlyTasks.syncQuotaUsage is what keeps it current.

* feat(web): explain how the cached storage figure is refreshed

The figure is only recalculated when a toggle is first turned on and then by
the nightly Sync quota usage task, so an admin who disabled that task would
see a permanently frozen number with nothing on screen saying why.

Adds the hint in en plus the nine maintained locales.

* docs(fork): mark the fork's hunks in upstream files and correct stale comments

- Mark every storage-usage hunk sitting in an otherwise upstream file with a
  Gallery-fork comment, starting with the greatest(0, ...) line inside
  updateUsage — the one a rebase resolver would most easily drop.
- Restore the circular-dependency note on the lazy StorageService import in
  base.service, matching the other three lazy-import sites in that file.
- Name the actual syncUsage call sites instead of "below this method".
- Drop the claim that physicalUsageInBytes is not in the userAdmin
  projection; it has been since database.ts added it.

* docs: state that quota accounting also changes what users are shown

The two toggles are no longer independent — enabling quota enforcement now
switches the displayed figure too — so the admin panel copy for the quota
switch and the two code comments that described the flag as display-only were
incomplete. Updated in en plus the nine maintained locales.

* chore(server): renumber AddPhysicalUsageInBytes off the #834 timestamp collision

#834 landed 1785000000000-AddFaceRepairLock, which shares a timestamp prefix
with this branch's 1785000000000-AddPhysicalUsageInBytes. Two migrations on one
timestamp is ambiguous and breaks the repo's unique-round-timestamp convention,
so move ours to 1791000000000 — above main's highest (1790000000000).

The migration has never merged, so no database has recorded the old name and no
pre-rename alias entry is needed in the revert script.

* refactor(server): collapse derivative storage accounting to one toggle

The two independent opt-ins (display and quota) could disagree, which meant
holding two numbers at once: a physicalUsageInBytes column, a migration, a
field on AuthUser/UserAdmin and both user DTOs, a ServerConfigDto flag telling
clients which number to render, and a matching read-site change everywhere
storage is shown.

One key, storageUsage.includeDerivatives, removes all of it. quotaUsageInBytes
again holds whatever the admin chose: upstream's originals-only statement when
off, the disk/S3 walk when on. Nothing downstream has to know which produced
it, so the column, the migration, the DTO fields and the enforcement branch in
requireQuota all revert to upstream.

UserRepository keeps upstream syncUsage verbatim and updateUsage exactly as
upstream has it; setUsage returns as the fork setter writing the same column.
BaseService.syncUsage is now either/or - running upstream's statement alongside
the walk would overwrite the walked figure with the originals-only one.

The resync trigger fires on any change of the flag, not just off->on: switching
off leaves a derivative-inclusive figure that also needs recomputing.

* test(server): cover the single storage-usage toggle

Adapts the handleUserSyncUsage block to setUsage and one flag, keeping the S3
prefix walk and the external-library exclusion, and pins the either/or: with
the toggle on, upstream's originals-only statement must not also run.

The storage-usage service spec now covers on->off queuing alongside off->on.
Tests that only asserted removed behaviour - quota enforcement picking a
column, the ServerConfigDto flag, the physicalUsageInBytes column shape - are
deleted rather than left asserting nothing. The medium spec is renamed to
match what it still covers.

* revert(web): return the storage meters to upstream, keep one admin switch

With quotaUsageInBytes always holding the figure the admin asked for, no
client needs to know the setting exists. StorageSpace, rail-storage and the
admin user page go back to reading that column directly, which retires the
shared derivation helper, the serverConfigManager.valueOrUndefined accessor
and the rail-storage tests covering the removed branch. rail-storage keeps its
original "Duplicated from StorageSpace.svelte" comment and parity block.

The settings panel keeps its refresh hint and drops to a single switch.

* fix(i18n): collapse the storage-usage strings to one toggle

Replaces the separate display and quota switch strings with a single pair
describing what the one switch now does - it moves the shown figure and the
enforced figure together - and rewords the refresh hint, which said "when you
first turn one of these on" and now has to cover turning it off as well.

en plus the nine maintained locales.

* chore(api): regenerate clients for the single storage-usage key

Only SystemConfigStorageUsageDto survives, with one field. ServerConfigDto and
UserAdminResponseDto return to upstream: no client reads a second usage number
or a flag selecting between them.

Regenerated from a built server (sync-open-api, oazapfts, generate-dart-sdk),
not hand-edited.

* docs: describe the single-toggle storage usage design

config-file.md documents storageUsage.includeDerivatives and drops the
two-flag interaction that no longer exists.

The ownership manifest keeps the paths this change still touches and drops the
ones that reverted to upstream, along with the migration glob for the deleted
column migration.

The plan doc described a two-column design the code no longer implements, so
it is rewritten as a design record of what shipped, including why the
two-toggle draft was abandoned.

* fix(server): format system-config.service.spec.ts per prettier

CI's Test & Lint Server job runs `prettier --check .` first, and a
misformatted storageUsage config-file test was failing it before any
test ran.

* fix(i18n): stop naming Immich in the storage-usage description, fix the refresh-hint clause

The derivatives-setting description named upstream Immich by product
name, which the branding pipeline has no override for — substituting
"Noodle Gallery" there would turn "matches upstream Immich" into the
false claim "matches upstream Noodle Gallery". Reworded to reference
the upstream default without naming the product, in en and all nine
translated locales.

The refresh-hint string also overstated what stops when the nightly
sync task is disabled: UserService.onAssetCreate keeps applying live
original-size deltas regardless, so only the reconciliation of
derivative bytes actually stops. Reworded the same nine locales.

* docs(server,web): drop stale plural wording from the single-toggle design

Both comments still described the abandoned two-toggle draft ("when
the toggles are enabled", "opt-ins for counting..."). There is only
one admin toggle now.

* test(server): include src/gallery in the coverage report

src/gallery/** now holds this PR's core storage-usage logic but was
missing from the coverage include list alongside cores/services/utils/
sql-tools, so it never showed up in coverage output.

* fix(server): lowercase the extracted derivative asset id

getExternalAssetIds returns Postgres-lowercased uuids, but
getDerivativeAssetId preserved the filename's own casing, so an
uppercase-cased filename would fail the set-membership check and get
counted as non-external. Unreachable today since filenames are built
from asset.id (already lowercase), but the test was locking in the
mismatch by asserting the uppercase value came back. Normalise the
extraction instead and flip the test to match.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog:feat Feature change for changelog documentation Improvements or additions to documentation rc Auto-build a release-candidate server image and post it on the PR 🗄️server 🖥️web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant