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>
This commit is contained in:
178
sidecar/handlers_people.go
Normal file
178
sidecar/handlers_people.go
Normal file
@@ -0,0 +1,178 @@
|
||||
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})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user