Files
mule-image/sidecar/handlers_people.go
dtoro 246d159d93 feat(metadata,people): richer metadata editing + face-naming flow
Metadata (sidebar):
- New editable fields: Title, Credits section (Artist/Copyright/
  License via Details), GPS Altitude.
- Video facts in the File section: Duration, FPS, Codec — required
  fixing videoFile(): PhotoPrism serializes MediaType as the bare word
  "video", so the old startsWith('video/') check never matched and the
  helper always fell back to the JPEG poster.
- Timezone correctness: buildTakenAtPatch no longer forces
  TakenAtLocal=UTC; it preserves the photo's existing UTC↔local offset
  (per-photo in bulk edits) so PhotoPrism can't clobber manual date
  edits when recomputing from TimeZone, and Year/Month/Day now derive
  from local wall-clock time.

People (was "disabled" — really: zero subjects because naming is what
creates a person, and the UI had no naming flow; prod has 40k face
markers in 790 unnamed clusters):
- Sidecar GET /api/sidecar/subjects — scoped people list via one
  markers→files→photos SQL pass (labels pattern), replacing the
  client-side probe-per-subject N+1 filter.
- Sidecar GET /api/sidecar/faces/unnamed — the caller's unnamed face
  clusters with count, crop thumb, and a representative marker UID.
- "Name new faces" panel on /tags/people: face-crop cards with inline
  name input; naming uses PhotoPrism's own flow (PUT /markers/:uid
  {Name, SubjSrc:manual}, verified against PP source) which creates
  the Subject and propagates across the cluster.
- Scoped proxy: marker PUT / subject-clear DELETE now allowed with
  per-marker ownership checks (was blanket-forbidden, which would have
  blocked naming for scoped users).
- Per-photo People chips in the sidebar from named Files[].Markers,
  linking to the person's page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 18:35:27 +02:00

179 lines
6.2 KiB
Go

package main
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// People (subjects + unnamed face clusters), scoped to the caller's
// BasePath the same way handleLabels scopes labels: proxy PhotoPrism's
// list, then one SQL pass over the caller's slice of the library to
// recompute counts and drop entries with nothing in scope.
//
// PhotoPrism CE treats subjects and face clusters as library-wide
// metadata (like albums and labels) — scoping here controls what each
// user *sees*, while the underlying entities stay shared.
// PpSubjectLite mirrors the fields the web client consumes from
// PhotoPrism's /api/v1/subjects rows.
type PpSubjectLite struct {
UID string `json:"UID"`
Type string `json:"Type"`
Slug string `json:"Slug"`
Name string `json:"Name"`
Alias string `json:"Alias"`
Favorite bool `json:"Favorite"`
Private bool `json:"Private"`
Excluded bool `json:"Excluded"`
Hidden bool `json:"Hidden"`
PhotoCount int `json:"PhotoCount"`
FileCount int `json:"FileCount"`
Thumb string `json:"Thumb"`
}
// handleSubjects proxies PhotoPrism's /api/v1/subjects and post-filters
// per-subject photo counts to the caller's BasePath, dropping subjects
// whose faces never appear in the caller's photos.
//
// Route: GET /api/sidecar/subjects (behind requireSession)
func handleSubjects(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
basePath := ctxBasePath(c)
query := c.Request.URL.RawQuery
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/subjects?"+query, token, nil)
if err != nil || !resp.OK {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream subjects request failed"})
return
}
var subjects []PpSubjectLite
if err := json.Unmarshal(resp.Body, &subjects); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse subjects"})
return
}
if basePath == "" || ppDb == nil {
c.JSON(http.StatusOK, subjects)
return
}
// One query: per-subject photo count + a representative face-crop
// thumb, restricted to the caller's path subtree. The join chain is
// markers → files → photos, matching how PhotoPrism binds a face to
// a picture.
type subjStat struct {
SubjUID string `gorm:"column:subj_uid"`
Cnt int64 `gorm:"column:cnt"`
Thumb string `gorm:"column:thumb"`
}
var stats []subjStat
if err := ppDb.Raw(`
SELECT m.subj_uid AS subj_uid,
COUNT(DISTINCT p.id) AS cnt,
SUBSTRING_INDEX(GROUP_CONCAT(m.thumb ORDER BY m.size DESC SEPARATOR 0x1f), 0x1f, 1) AS thumb
FROM markers m
JOIN files f ON f.file_uid = m.file_uid AND f.file_missing = 0
JOIN photos p ON p.photo_uid = f.photo_uid AND p.deleted_at IS NULL
WHERE m.marker_type = 'face'
AND m.marker_invalid = 0
AND m.subj_uid IS NOT NULL AND m.subj_uid <> ''
AND (p.photo_path = ? OR p.photo_path LIKE ?)
GROUP BY m.subj_uid
`, basePath, basePath+"/%").Scan(&stats).Error; err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "subject stats query failed"})
return
}
cntMap := make(map[string]int64, len(stats))
thumbMap := make(map[string]string, len(stats))
for _, s := range stats {
cntMap[s.SubjUID] = s.Cnt
thumbMap[s.SubjUID] = s.Thumb
}
filtered := make([]PpSubjectLite, 0, len(stats))
for _, s := range subjects {
cnt, ok := cntMap[s.UID]
if !ok || cnt == 0 {
continue
}
s.PhotoCount = int(cnt)
if th := thumbMap[s.UID]; th != "" {
s.Thumb = th
}
filtered = append(filtered, s)
}
c.JSON(http.StatusOK, filtered)
}
}
// unnamedFaceCluster is one face cluster PhotoPrism has detected but
// nobody has named yet. Naming happens the same way PhotoPrism's own
// People→New tab does it: PUT /api/v1/markers/<markerUid> with
// {Name, SubjSrc:"manual"} — PhotoPrism then creates the Subject and
// propagates it across the cluster (verified against
// internal/api/markers.go + frontend/src/model/face.js).
type unnamedFaceCluster struct {
FaceID string `json:"faceId" gorm:"column:face_id"`
// Photos under the caller's scope carrying this face.
Count int64 `json:"count" gorm:"column:cnt"`
// Marker crop hash — renders via /api/v1/t/<thumb>/<token>/tile_320.
Thumb string `json:"thumb" gorm:"column:thumb"`
// Representative marker (largest face in scope) — the PUT target
// when the user names this cluster.
MarkerUID string `json:"markerUid" gorm:"column:marker_uid"`
}
// handleUnnamedFaces lists face clusters awaiting a name, scoped to the
// caller's BasePath (admins with no BasePath see the whole library).
// Ordered by in-scope photo count so the most prominent people surface
// first.
//
// Route: GET /api/sidecar/faces/unnamed (behind requireSession)
func handleUnnamedFaces(ppDb *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if ppDb == nil {
c.JSON(http.StatusOK, gin.H{"clusters": []unnamedFaceCluster{}})
return
}
basePath := ctxBasePath(c)
where := ""
args := []any{}
if basePath != "" {
where = "AND (p.photo_path = ? OR p.photo_path LIKE ?)"
args = []any{basePath, basePath + "/%"}
}
var clusters []unnamedFaceCluster
if err := ppDb.Raw(`
SELECT m.face_id AS face_id,
COUNT(DISTINCT p.id) AS cnt,
SUBSTRING_INDEX(GROUP_CONCAT(m.thumb ORDER BY m.size DESC SEPARATOR 0x1f), 0x1f, 1) AS thumb,
SUBSTRING_INDEX(GROUP_CONCAT(m.marker_uid ORDER BY m.size DESC SEPARATOR 0x1f), 0x1f, 1) AS marker_uid
FROM markers m
JOIN files f ON f.file_uid = m.file_uid AND f.file_missing = 0
JOIN photos p ON p.photo_uid = f.photo_uid AND p.deleted_at IS NULL
JOIN faces fc ON fc.id = m.face_id AND fc.face_hidden = 0
WHERE m.marker_type = 'face'
AND m.marker_invalid = 0
AND (m.subj_uid IS NULL OR m.subj_uid = '')
AND m.face_id <> ''
`+where+`
GROUP BY m.face_id
ORDER BY cnt DESC
LIMIT 60
`, args...).Scan(&clusters).Error; err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "face cluster query failed"})
return
}
if clusters == nil {
clusters = []unnamedFaceCluster{}
}
c.JSON(http.StatusOK, gin.H{"clusters": clusters})
}
}