diff --git a/sidecar/handlers_people.go b/sidecar/handlers_people.go new file mode 100644 index 0000000..6dc676e --- /dev/null +++ b/sidecar/handlers_people.go @@ -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/ 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}) + } +} diff --git a/sidecar/handlers_ppproxy.go b/sidecar/handlers_ppproxy.go index 93cfe13..2b6527d 100644 --- a/sidecar/handlers_ppproxy.go +++ b/sidecar/handlers_ppproxy.go @@ -185,6 +185,27 @@ func proxyToken(r *http.Request) string { return r.Header.Get("X-Session-ID") } +// markerWithinBase reports whether a marker's underlying photo lives under +// base. Fails closed: no DB handle or unknown marker → false. +func markerWithinBase(ppDb *gorm.DB, markerUID, base string) bool { + if ppDb == nil || markerUID == "" { + return false + } + var n int64 + err := ppDb.Table("markers m"). + Joins("JOIN files f ON f.file_uid = m.file_uid"). + Joins("JOIN photos p ON p.photo_uid = f.photo_uid"). + Where("m.marker_uid = ?", markerUID). + Where("p.deleted_at IS NULL"). + Where("p.photo_path = ? OR p.photo_path LIKE ?", base, base+"/%"). + Count(&n).Error + if err != nil { + slog.Warn("pp-proxy: marker ownership query failed", "err", err) + return false + } + return n > 0 +} + // batchUIDsWithinBase validates that every photo UID in a batch body lives // under base, using one SQL query against PhotoPrism's photos table. Fails // closed: no DB handle, unknown UIDs, or any path outside base → false. @@ -344,13 +365,28 @@ func handlePPProxy(cfg *Config, ppDb *gorm.DB) gin.HandlerFunc { // Albums (heaps), labels, subjects, faces: shared across users by // design in CE — reads and mutations pass through; the photos inside - // any of them stay path-scoped by the rules above. + // any of them stay path-scoped by the rules above. Note that naming + // a face (marker PUT below) creates/updates a shared Subject the + // same way album/label edits are shared. case rest == "albums" || strings.HasPrefix(rest, "albums/") || rest == "labels" || strings.HasPrefix(rest, "labels/") || rest == "subjects" || strings.HasPrefix(rest, "subjects/") || rest == "faces" || strings.HasPrefix(rest, "faces/"): proxy.ServeHTTP(c.Writer, c.Request) + // Marker mutations (face naming / clearing): ownership-checked — + // the marker's file must belong to a photo under the caller's + // BasePath. This is how the web client names people (PhotoPrism's + // own naming flow is PUT /markers/:uid {Name, SubjSrc:"manual"}). + case (method == http.MethodPut && strings.HasPrefix(rest, "markers/") && strings.Count(rest, "/") == 1) || + (method == http.MethodDelete && strings.HasPrefix(rest, "markers/") && strings.HasSuffix(rest, "/subject")): + markerUID := strings.TrimSuffix(strings.TrimPrefix(rest, "markers/"), "/subject") + if !markerWithinBase(ppDb, markerUID, base) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "marker not found"}) + return + } + proxy.ServeHTTP(c.Writer, c.Request) + default: slog.Info("pp-proxy: blocked", "user", user.UserName, "method", method, "path", rest) forbid(c) diff --git a/sidecar/main.go b/sidecar/main.go index d0c3d8a..1f9df69 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -109,6 +109,8 @@ func main() { auth.GET("/labels", handleLabels(pp, ppDb)) auth.GET("/counts", handleScopedCounts(ppDb)) auth.GET("/countries", handleCountries(ppDb)) + auth.GET("/subjects", handleSubjects(pp, ppDb)) + auth.GET("/faces/unnamed", handleUnnamedFaces(ppDb)) } // User-scoped photos — post-filters by BasePath so review/archive diff --git a/web/src/lib/components/people/NewFacesPanel.svelte b/web/src/lib/components/people/NewFacesPanel.svelte new file mode 100644 index 0000000..e75791a --- /dev/null +++ b/web/src/lib/components/people/NewFacesPanel.svelte @@ -0,0 +1,112 @@ + + + +{#if facesQuery.isPending} + +{:else if clusters.length > 0} +
+
+

+ Name new faces +

+

+ Faces PhotoPrism detected but nobody has named yet. Naming one creates a person and tags + every matching photo. +

+
+
+ {#each clusters as cluster (cluster.faceId)} +
+
+ Unnamed face + + {cluster.count} + +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + void submit(cluster); + } + }} + onblur={() => void submit(cluster)} + /> +
+ {/each} +
+
+{/if} diff --git a/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte b/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte index 64766a0..919985e 100644 --- a/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte +++ b/web/src/lib/components/sidebar/BulkMetadataSidebar.svelte @@ -82,11 +82,13 @@ () => patchTargets( ids, - buildTakenAtPatch(iso), + // Per-photo patch so each photo keeps its own UTC↔local + // offset when the date is stamped across a selection. + (p) => buildTakenAtPatch(iso, p), label, (p) => p.TakenAt - ? buildTakenAtPatch(p.TakenAt) + ? buildTakenAtPatch(p.TakenAt, p) : ({ TakenSrc: '' } as UpdatePhotoBody) ), label diff --git a/web/src/lib/components/sidebar/RightSidebar.svelte b/web/src/lib/components/sidebar/RightSidebar.svelte index 4dbb93c..5e5dbee 100644 --- a/web/src/lib/components/sidebar/RightSidebar.svelte +++ b/web/src/lib/components/sidebar/RightSidebar.svelte @@ -25,6 +25,7 @@ Star, Tag, Timer, + User, X } from 'lucide-svelte'; import { @@ -44,7 +45,13 @@ import { isAuthenticated } from '$lib/stores/session.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte'; import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte'; - import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism'; + import { + isVideo, + photoNameAndDir, + primaryFile, + videoFile, + type PpPhoto + } from '$lib/types/photoprism'; import { navigateToFolder, navigateToTag, setSearch, setSection } from '$lib/stores/filters.svelte'; import { goto } from '$app/navigation'; import { COLOR_SWATCHES } from '$lib/utils/tagGroups'; @@ -59,14 +66,19 @@ const qc = useQueryClient(); let basename = $state(''); + let title = $state(''); let caption = $state(''); let takenAt = $state(''); let lat = $state(''); let lng = $state(''); + let altitude = $state(''); let country = $state(''); let keywords = $state([]); let keywordDraft = $state(''); let renaming = $state(false); + let artist = $state(''); + let copyright = $state(''); + let license = $state(''); /** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory * prefix and basename. Sidecar's rename endpoint only accepts a bare @@ -80,16 +92,21 @@ $effect(() => { const pf = primaryFile(photo); basename = splitName(pf.Name ?? '').base; + title = photo.Title ?? ''; caption = photo.Caption ?? ''; takenAt = (photo.TakenAt ?? '').slice(0, 10); lat = photo.Lat ? String(photo.Lat) : ''; lng = photo.Lng ? String(photo.Lng) : ''; + altitude = photo.Altitude ? String(photo.Altitude) : ''; country = photo.Country && photo.Country !== 'zz' ? photo.Country : ''; const det = photo.Details ?? {}; keywords = (det.Keywords ?? '') .split(',') .map((k) => k.trim()) .filter(Boolean); + artist = det.Artist ?? ''; + copyright = det.Copyright ?? ''; + license = det.License ?? ''; }); const patchMutation = createMutation(() => ({ @@ -145,6 +162,11 @@ if (caption === (photo.Caption ?? '')) return; commit({ Caption: caption, CaptionSrc: 'manual' }); } + function commitTitle() { + const next = title.trim(); + if (next === (photo.Title ?? '')) return; + commit({ Title: next, TitleSrc: 'manual' }); + } const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt)); // Path-based date guess. Scoped to the EXIF Stripped review tab: those // are the photos with definitionally-untrusted dates, and showing the @@ -185,14 +207,16 @@ const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z'; const iso = `${takenAt}${tail}`; if (iso === photo.TakenAt) return; - commit(buildTakenAtPatch(iso)); + commit(buildTakenAtPatch(iso, photo)); } function commitGps() { const nlat = parseFloat(lat); const nlng = parseFloat(lng); + const nalt = parseFloat(altitude); const patch: UpdatePhotoBody = {}; if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat; if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng; + if (!Number.isNaN(nalt) && nalt !== photo.Altitude) patch.Altitude = nalt; if (Object.keys(patch).length) commit(patch); } function commitCountry() { @@ -202,7 +226,7 @@ commit({ Country: next || 'zz', CountrySrc: 'manual' }); } - type DetailsKey = 'Keywords'; + type DetailsKey = 'Keywords' | 'Artist' | 'Copyright' | 'License'; function commitDetails(field: DetailsKey, value: string) { const prev = (photo.Details ?? {})[field] ?? ''; if (value === prev) return; @@ -283,7 +307,42 @@ const currentRating = $derived(photoMark.rating ?? 0); const currentColor = $derived(photoMark.color ?? ''); + // Named face markers across all file variants, deduped by subject. + // Slug mirrors PhotoPrism's slugify (lowercase, diacritics stripped, + // non-alphanumerics collapsed to '-') so the person link resolves the + // same drill URL the sidebar list uses. + function personSlug(name: string): string { + return name + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + const peopleChips = $derived.by(() => { + const seen = new Map(); + for (const f of photo.Files ?? []) { + for (const m of f.Markers ?? []) { + if (m.Invalid || !m.Name || !m.SubjUID || seen.has(m.SubjUID)) continue; + seen.set(m.SubjUID, { subjUid: m.SubjUID, name: m.Name, slug: personSlug(m.Name) }); + } + } + return [...seen.values()]; + }); + const pf = $derived(primaryFile(photo)); + // Video facts come from the video variant (primary is often the JPEG + // poster for Live Photos / transcoded clips). + const vf = $derived(isVideo(photo) ? videoFile(photo) : null); + const durationStr = $derived.by(() => { + // PpFile.Duration is Go time.Duration → nanoseconds. + const ns = vf?.Duration ?? 0; + if (ns <= 0) return ''; + const totalSec = Math.round(ns / 1_000_000_000); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}:${String(s).padStart(2, '0')}`; + }); const dirPath = $derived(splitName(pf.Name ?? '').dir); const folderLabel = $derived(dirPath ? `${dirPath}/` : '/'); const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—'); @@ -508,6 +567,17 @@
+
+
Title
+ +
+
Note