Skip to content

[pull] develop from photoprism:develop - #1775

Merged
pull[bot] merged 15 commits into
Uncodedtech:developfrom
photoprism:develop
Aug 31, 2026
Merged

[pull] develop from photoprism:develop#1775
pull[bot] merged 15 commits into
Uncodedtech:developfrom
photoprism:develop

Conversation

@pull

@pull pull Bot commented Aug 31, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

Adds the nullable subjects.subj_birthday column, created by GORM auto-migration,
and surfaces it in the Edit Person dialog through Vuetify's date picker. It is an
operator assertion like the verified flag: knowledge only a person has, which
nothing in the pipeline can derive, and it is written by Subject.SaveForm alone.

Subject.SetBirthday normalizes to UTC midnight, reading the calendar date in the
submitted value's own location, because the column carries a time and a zone that
a birthday does not have. Values outside BirthYearMin to today are rejected, with
a day of headroom at the top for the eastern zones; the frontend mirrors the
constant so the picker cannot offer a year the API refuses.

Two places are hardened for a column that can be null, since every field in these
projections could previously only be empty:

- form.NewSubject gives the form its own copy of each pointer value, so binding a
  request writes into the form rather than back into the model it came from.
- refetchResults in the people list treats null as a value rather than a missing
  field, so a cleared column reaches the loaded model.

The Birthday label still has to reach translations.pot in a catalog commit.
Subject.SaveForm renames through UpdateName, which saves the row, renames every
marker and flags the person's photos for re-check as it goes. Anything the form
can reject is now checked ahead of it, so a request that is refused leaves the
index as it found it rather than committing the rename and reporting failure.

Also closes the gaps a review found in the date-of-birth tests, each of which
would have stayed green through the regression it names:

- The lower bound is pinned to its literal year on both sides, so moving the
  constant without moving the picker's copy fails.
- The future-date headroom is measured in whole days; subtracting an hour landed
  back on today for runs starting before 01:00 UTC, where the case proved nothing.
- Both projections are asserted to serialize the field as null rather than to
  leave the key out, which the client reads as "no such field" and never sends.
- The frontend reads the day from the instant in UTC, so an offset rendering
  cannot move it, and the timezone helper restores an unset TZ by unsetting it.
search.UserSubjects is the session-scoped entry point the API uses, mirroring
UserPhotos beside Photos, which stays unscoped for internal callers. A role the
ACL denies AccessPrivate has private forced to no and all cleared, since all
skips the visibility filters rather than relaxing one of them.

The visibility and soft-delete filters apply to every branch of the query,
including the uid lookup - that shortcut skips only the content filters, so
reloading one person by uid returns that row whatever its file count. A subject
a session may not read answers as not found.

Rejected form values answer 400: SaveForm wraps them in the new
entity.ErrInvalidValue and UpdateSubject maps it to a bad request, keeping a
value the client has to correct out of the server-fault log.

The people list carries its own hidden filter into the uid-scoped refetch, so the
refetch asks the question the list asked. Its result-count notification is
silenced for the tab that is not on screen: both tabs are mounted eagerly and
search when they are created.

The edit dialog reuses the existing Birth Date message rather than adding one.
A client session carries no user, so resolving a user role to scope the results
answers RoleNone and narrows what the client role was authorized with.
sessionGrantsPeople mirrors sessionGrantsPhotos: the client role must permit it,
and a client without a user is evaluated on that role alone.

SaveForm now splits validating a value from applying it, so a rename that merges
this person into another leaves nothing assigned to an entity the handler
serializes but never saves. NormalizeBirthday is the validating half.

FindSubjectForSession is the shared lookup all four subject handlers use, so the
read that refuses and the three writes cannot drift apart. A rejected value is
logged at warn beside the 400, since AbortBadRequest reaches only the debug log.

The tests that were missing are the point of the commit: each of the two defects
above has one that fails without its fix, the handlers are pinned together, and
the bound, the scoping predicate and the validating half are covered directly.
The flag had a definition, a writer in SaveForm and a session-scoped read path,
but nothing that set it - so it did nothing for anyone who was not calling the
API by hand. The checkbox is what makes it a feature.

Placed beside Hidden, since both are visibility, and laid out 12 / 6 / 3 so the
four short labels sit on one row on a desktop, two on a tablet and one per row on
a phone. It reuses the existing Private message, so the catalog is unchanged.
Four across fits the English labels and not the translated ones - Verifiziert and
Verborgen are half again as long as Verified and Hidden, in a dialog capped at
500px whatever the screen. Two per row from the small breakpoint up, one per row
below it.
The column records how many embeddings were averaged into embedding_json, which
is what says whether a centroid was computed at all. UpdateMatchStats had been
writing the markers matched in a pass into it, so one field carried two meanings
and a one-embedding cluster read as a grouping once it recruited. It keeps the
radius ratchet and gives up the count; SetSampleRadius replaces SetMatchStats for
the same reason, since measuring an extent over the members says nothing about
the centroid, which it deliberately does not recompute.

A single embedding also has no extent, so SetEmbeddings stores Epsilon where it
stored the full cluster radius. A labeled example now reaches MatchDist and no
further, rather than casting a cluster's width on the evidence of one photograph.

face.ManualClusterCore is the number of embeddings that make a cluster: fewer are
not merged, and are not offered for matching. Lower than ClusterCore because each
manual face already asserts an identity, so the risk is not chance proximity in
unlabeled data. Merging three produces a real centroid and a measured radius.

Match ordering now counts members through the join FaceReports already uses,
rather than reading samples for a question it does not answer.

Tests that encoded the old rules are rewritten rather than relaxed, including a
pair that links, waits, and completes in one pass when a third label arrives.
The audit repaired clusters stored without a radius by writing the full cluster
radius, on the premise that no write path leaves a row at or below Epsilon. That
premise no longer holds, and neither does the remedy: a singleton has no extent
to repair, and a real cluster needs one measured over its members rather than a
constant. audit --fix now recomputes from the markers a cluster holds and leaves
one-embedding rows alone.

face.RadiusFrom answers an unmeasurable spread with the floor for the same
reason, so it agrees with SetEmbeddings again rather than with what SetEmbeddings
used to do.
The README described the sample count as a match high-water mark and a cluster
without a measurable extent as reaching the full cluster radius. Both moved, so
the section that explained them is rewritten around what a single embedding
actually is: a labeled example, with neither a centroid nor a radius, until a
core of them merges into one.
The gate went in on the read side alone, so the paths that create and feed
sub-threshold clusters kept running and left rows nothing could reach again.

Face.MatchMarkers now refuses below two embeddings. It runs once, when a named
marker gets a cluster, and the matcher never offers that cluster again - so a
labeled example left free to adopt there kept exactly the reach the gate removes.
The migration stops rebuilding a subject from a single embedding, and names the
subjects it skipped, since "this person stopped being recognized after I switched
models" is otherwise undiagnosable. face-cluster-core is bounded below at 2,
which a value of 1 would otherwise turn into a library of clusters no pass offers.

Matching admits a pair. One embedding is not a centroid, it is the embedding;
two are already an average, and a pair that exists is worth using even though
ManualClusterCore still refuses to make one. That keeps existing two-face
clusters working rather than requiring a merge that may never come.

Ordering by a joined member count is reverted. It cost a full scan of the markers
table with a LENGTH() on the embedding blob, on the query the matcher runs twice
per wake and the collision pass re-runs after every resolution - roughly twenty
times the old cost, for an ordering that only decides how early a winner is met.
It also sorted brand-new clusters last, which is the wrong end.

Restores the audit coverage the rewrite dropped - the scoping clauses, the floor
boundary and that Audit still calls the measurement - and replaces four
assertions that passed for the wrong reason, each confirmed by mutation.
@pull pull Bot locked and limited conversation to collaborators Aug 31, 2026
@pull pull Bot added the ⤵️ pull label Aug 31, 2026
@pull
pull Bot merged commit 448d9d3 into Uncodedtech:develop Aug 31, 2026
2 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant