fix(server): apply locked-folder visibility to the motion half of a live photo (#869) - #924
Open
Deeds67 wants to merge 2 commits into
Open
fix(server): apply locked-folder visibility to the motion half of a live photo (#869)#924Deeds67 wants to merge 2 commits into
Deeds67 wants to merge 2 commits into
Conversation
…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.
…'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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Moving the still to the Locked Folder writes
visibility = lockedon the still row only — nothing walks the pairing, so the motion row keeps thehiddenvisibility it was given when the pair was linked. Every "is this in the Locked Folder?" gate was spelledvisibility != locked, which ahiddenrow passes, so the motion half was treated as an ordinary hidden asset.Hidden assets normally have no CLIP embedding (
handleEncodeClipskips them), which is why this is not visible on every Live Photo. But the web uploader sends the.movfirst as a standalone Timeline asset and only links it when the.heicfollows, 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/isNotLockedAssetinsrc/utils/database.tsreplace the hand-written predicate everywhere it appeared. A motion video counts as locked for as long as its still is:searchAssetBuilderLegacy(smart search, metadata, statistics, random, large assets, smart facets)visibility != locked— the motion half of a locked Live Photo ranked and returned normally.GET /search/filter-suggestions(buildFilteredAssetIds)getAccessibleTags!= lockedscope.AccessRepository.asset.checkOwnerAccessasset.read/view/… for any non-locked owned asset, soGET /assets/:motionId/thumbnail(and playback, info, download) answered while the folder was locked.PersonRepositoryface gates (visibleFaceOnAsset,getFaces) andAccessRepository.person.checkUnlockedThumbnailAccessasset.visibility, which the motion half passed — a person or representative face backed only by a locked Live Photo's motion video stayed reachable.lockedandnot-lockedarms 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
visibilityis 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
isNotLockedAssetis written as a conjunction (visibility != locked AND NOT EXISTS (...)) rather thannot(isLockedAsset). De Morgan makes them identical, but only the conjunction leaves a standaloneNOT EXISTSthat Postgres can plan as an anti-join — underNOT (... OR ...)it collapses into a hashed SubPlan in the scan filter and costs the outer scan its parallelism.Migration
1785000000000adds a partial index onasset ("livePhotoVideoId", "visibility") WHERE "livePhotoVideoId" IS NOT NULLto back that anti-join. The predicate cannot filter onvisibility = 'locked'directly: that enum value is added byALTER TYPE ... ADD VALUE, and on a fresh database every migration runs inside one transaction, where Postgres refuses a not-yet-committed enum value — carryingvisibilityas 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):
id IN (20 ids))ORDER BY fileCreatedAt LIMIT 250)Testing
too many clientspool exhaustion, which passes on re-run in isolation.tsc --noEmit,pnpm lint, prettier clean. SQL query docs regenerated, andmigrations:generatereports no drift against the declarative schema on a fresh database.scripts/revert-to-immich.sql(index +migration_overridesrow).Out of scope
The reporter's suggested fix was to write
visibility = lockedonto the motion row itself. That was not taken: it needs a backfill for already-locked Live Photos, correct restore semantics (back tohidden, 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.