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/ 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///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}) } }