Skip to content

fix(server): apply locked-folder visibility to the motion half of a live photo (#869) - #924

Open
Deeds67 wants to merge 2 commits into
mainfrom
worktree-fix-869-live-photo-locked
Open

fix(server): apply locked-folder visibility to the motion half of a live photo (#869)#924
Deeds67 wants to merge 2 commits into
mainfrom
worktree-fix-869-live-photo-locked

Conversation

@Deeds67

@Deeds67 Deeds67 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #869 (after #897), from this report: a Live Photo moved to the Locked Folder could still turn up in /photos?q=<person name>, and the result's thumbnail loaded without entering the PIN.

Why

A Live Photo is two asset rows, linked one way:

still  (visibility = timeline)  --livePhotoVideoId-->  motion  (visibility = hidden)

Moving the still to the Locked Folder writes visibility = locked on the still row only — nothing walks the pairing, so the motion row keeps the hidden visibility it was given when the pair was linked. Every "is this in the Locked Folder?" gate was spelled visibility != locked, which a hidden row passes, so the motion half was treated as an ordinary hidden asset.

Hidden assets normally have no CLIP embedding (handleEncodeClip skips them), which is why this is not visible on every Live Photo. But the web uploader sends the .mov first as a standalone Timeline asset and only links it when the .heic follows, so a motion video that finished its Smart Search / face-detection jobs before the pairing keeps that embedding — and its faces — for good.

What changed

isLockedAsset / isNotLockedAsset in src/utils/database.ts replace the hand-written predicate everywhere it appeared. A motion video counts as locked for as long as its still is:

Surface Before After
searchAssetBuilderLegacy (smart search, metadata, statistics, random, large assets, smart facets) visibility != locked — the motion half of a locked Live Photo ranked and returned normally. Motion half excluded unless the session is elevated.
GET /search/filter-suggestions (buildFilteredAssetIds) The motion row carries its own EXIF, so a locked Live Photo's country/city/camera appeared among the suggestions. Excluded on the same rule.
getAccessibleTags Same != locked scope. Same rule, for consistency.
AccessRepository.asset.checkOwnerAccess Grants asset.read/view/… for any non-locked owned asset, so GET /assets/:motionId/thumbnail (and playback, info, download) answered while the folder was locked. Unelevated owner is refused; elevated is unchanged.
PersonRepository face gates (visibleFaceOnAsset, getFaces) and AccessRepository.person.checkUnlockedThumbnailAccess #897 gated these on asset.visibility, which the motion half passed — a person or representative face backed only by a locked Live Photo's motion video stayed reachable. Same rule; the locked and not-locked arms stay exact complements, so #897's "locked-only person" logic is unchanged.

Deliberately unchanged: an elevated session still sees both halves; a Live Photo outside the Locked Folder keeps its motion half searchable; the motion row's stored visibility is untouched, so the fix is retroactive for Live Photos that are already locked and needs no backfill, and the Locked Folder grid does not start listing motion videos as separate items.

Plan shape

isNotLockedAsset is written as a conjunction (visibility != locked AND NOT EXISTS (...)) rather than not(isLockedAsset). De Morgan makes them identical, but only the conjunction leaves a standalone NOT EXISTS that Postgres can plan as an anti-join — under NOT (... OR ...) it collapses into a hashed SubPlan in the scan filter and costs the outer scan its parallelism.

Migration 1785000000000 adds a partial index on asset ("livePhotoVideoId", "visibility") WHERE "livePhotoVideoId" IS NOT NULL to back that anti-join. The predicate cannot filter on visibility = 'locked' directly: that enum value is added by ALTER TYPE ... ADD VALUE, and on a fresh database every migration runs inside one transaction, where Postgres refuses a not-yet-committed enum value — carrying visibility as the second index column buys the same index-only anti-join.

Measured on a synthetic 500k-asset table (100k Live Photos, 5k of them locked — well past a realistic ratio):

Query shape Before After, no index After, with index
Owner access check (id IN (20 ids)) 0.05 ms 0.14 ms 0.56 ms
Search page (ORDER BY fileCreatedAt LIMIT 250) 30 ms 85 ms 37 ms
Filter suggestions (aggregate over all assets) 26 ms 106 ms 31 ms

Testing

  • Test-first for both reported symptoms and for each sibling gate; 13 new medium tests against a real database.
  • Mutation-verified — every new assertion fails for its own reason and nothing else: dropping the Live Photo arm fails all 7 "omits/refuses" tests; treating every paired motion video as locked fails the 2 "keeps an unlocked Live Photo searchable" guards; applying the gate to elevated sessions fails the elevated guard.
  • Server unit suite 5251 green; medium suites for the touched and adjacent specs (318) green; full medium suite green apart from local too many clients pool exhaustion, which passes on re-run in isolation.
  • tsc --noEmit, pnpm lint, prettier clean. SQL query docs regenerated, and migrations:generate reports no drift against the declarative schema on a fresh database.
  • New fork migration registered in scripts/revert-to-immich.sql (index + migration_overrides row).

Out of scope

The reporter's suggested fix was to write visibility = locked onto the motion row itself. That was not taken: it needs a backfill for already-locked Live Photos, correct restore semantics (back to hidden, not to the still's new visibility), and it would list motion videos as separate items in the Locked Folder grid. Resolving the pairing at read time avoids all three and covers existing data immediately.

…ive photo (#869)

A live photo is stored as two asset rows — the still and its paired motion
video, linked one way by `still.livePhotoVideoId -> motion.id`. Moving the
still into the Locked Folder writes `visibility = locked` on the still row
only; the motion row keeps the `hidden` visibility it was given when the pair
was linked. Every gate that asked `visibility != locked` therefore treated the
motion half as an ordinary hidden asset, so it stayed in search results and its
thumbnail stayed readable for a session that had not entered the PIN.

Add `isNotLockedAsset` / `isLockedAsset` in src/utils/database.ts and use them
wherever those gates were spelled out by hand: the shared search builder, the
tag and filter-suggestion scopes, owner asset access, and the person face and
thumbnail gates. A motion video now counts as locked for as long as its still
is, and stops counting the moment the still leaves the Locked Folder — no
stored state, so already-locked live photos are covered without a backfill.

`isNotLockedAsset` is written as a conjunction rather than
`not(isLockedAsset)`: De Morgan makes them identical, but only the conjunction
leaves a standalone `NOT EXISTS` that Postgres plans as an anti-join. The new
partial index on asset ("livePhotoVideoId", "visibility") backs that anti-join;
its predicate cannot filter on `visibility = 'locked'` because that enum value
is added by ALTER TYPE ... ADD VALUE and every migration runs in one
transaction on a fresh database.
@Deeds67 Deeds67 added the changelog:fix Bug fix for changelog label Aug 4, 2026
…'s motion half (#869)

The `not-locked` resolution only covers a session that lets the server pick the
visibility filter. `visibility` is a client-settable DTO field and `hidden` is
what the motion row carries, so passing it explicitly took the equality branch
and walked past the gate. Thread a server-derived `hasElevatedPermission`
through the timeline bucket, asset statistics and search builders so the pairing
gate is re-applied in exactly that case; it is always written after the DTO
spread and omitting it fails closed.

Also gate the partner arm, which grants `hidden` so partners can play live
photos and therefore still served a locked pair's video, thumbnail and EXIF.

Renames the migration to 1785869000000 so the timestamp carries the issue
number instead of a round value two branches could both pick, and records the
convention in AGENTS.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog:fix Bug fix for changelog 🗄️server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant