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>
179 lines
6.2 KiB
Go
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})
|
|
}
|
|
}
|