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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,6 +185,27 @@ func proxyToken(r *http.Request) string {
|
|||||||
return r.Header.Get("X-Session-ID")
|
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
|
// batchUIDsWithinBase validates that every photo UID in a batch body lives
|
||||||
// under base, using one SQL query against PhotoPrism's photos table. Fails
|
// under base, using one SQL query against PhotoPrism's photos table. Fails
|
||||||
// closed: no DB handle, unknown UIDs, or any path outside base → false.
|
// 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
|
// Albums (heaps), labels, subjects, faces: shared across users by
|
||||||
// design in CE — reads and mutations pass through; the photos inside
|
// 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/") ||
|
case rest == "albums" || strings.HasPrefix(rest, "albums/") ||
|
||||||
rest == "labels" || strings.HasPrefix(rest, "labels/") ||
|
rest == "labels" || strings.HasPrefix(rest, "labels/") ||
|
||||||
rest == "subjects" || strings.HasPrefix(rest, "subjects/") ||
|
rest == "subjects" || strings.HasPrefix(rest, "subjects/") ||
|
||||||
rest == "faces" || strings.HasPrefix(rest, "faces/"):
|
rest == "faces" || strings.HasPrefix(rest, "faces/"):
|
||||||
proxy.ServeHTTP(c.Writer, c.Request)
|
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:
|
default:
|
||||||
slog.Info("pp-proxy: blocked", "user", user.UserName, "method", method, "path", rest)
|
slog.Info("pp-proxy: blocked", "user", user.UserName, "method", method, "path", rest)
|
||||||
forbid(c)
|
forbid(c)
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ func main() {
|
|||||||
auth.GET("/labels", handleLabels(pp, ppDb))
|
auth.GET("/labels", handleLabels(pp, ppDb))
|
||||||
auth.GET("/counts", handleScopedCounts(ppDb))
|
auth.GET("/counts", handleScopedCounts(ppDb))
|
||||||
auth.GET("/countries", handleCountries(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
|
// User-scoped photos — post-filters by BasePath so review/archive
|
||||||
|
|||||||
112
web/src/lib/components/people/NewFacesPanel.svelte
Normal file
112
web/src/lib/components/people/NewFacesPanel.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<!--
|
||||||
|
"Name new faces" — the missing half of the People feature. PhotoPrism
|
||||||
|
only creates a person once someone names a detected face cluster, so a
|
||||||
|
library can have tens of thousands of face markers and still show an
|
||||||
|
empty People list. This panel surfaces the caller's unnamed clusters
|
||||||
|
(scoped server-side to their BasePath) as face-crop cards with an
|
||||||
|
inline name input; naming goes through PhotoPrism's own flow (PUT on
|
||||||
|
the cluster's representative marker), which creates the Subject and
|
||||||
|
propagates it across the cluster.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import {
|
||||||
|
listUnnamedFaces,
|
||||||
|
nameFaceCluster,
|
||||||
|
type UnnamedFaceCluster
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { InlineLoader } from '$lib/components/feedback';
|
||||||
|
import { UserPlus } from 'lucide-svelte';
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const facesQuery = createQuery<UnnamedFaceCluster[]>(() => ({
|
||||||
|
queryKey: ['faces', 'unnamed'],
|
||||||
|
queryFn: listUnnamedFaces,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
let drafts = $state<Record<string, string>>({});
|
||||||
|
let busy = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
async function submit(cluster: UnnamedFaceCluster) {
|
||||||
|
const name = (drafts[cluster.faceId] ?? '').trim();
|
||||||
|
if (!name || busy[cluster.faceId]) return;
|
||||||
|
busy[cluster.faceId] = true;
|
||||||
|
try {
|
||||||
|
await nameFaceCluster(cluster.markerUid, name);
|
||||||
|
toast.success(`Named ${name}`, {
|
||||||
|
description:
|
||||||
|
'PhotoPrism links the whole cluster in the background — the photo count may keep growing.'
|
||||||
|
});
|
||||||
|
drafts[cluster.faceId] = '';
|
||||||
|
void qc.invalidateQueries({ queryKey: ['faces', 'unnamed'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['subjects'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Naming failed');
|
||||||
|
} finally {
|
||||||
|
busy[cluster.faceId] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusters = $derived(facesQuery.data ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if facesQuery.isPending}
|
||||||
|
<InlineLoader label="Looking for unnamed faces…" />
|
||||||
|
{:else if clusters.length > 0}
|
||||||
|
<section class="space-y-3">
|
||||||
|
<header class="space-y-0.5">
|
||||||
|
<h2 class="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
|
<UserPlus class="h-4 w-4" /> Name new faces
|
||||||
|
</h2>
|
||||||
|
<p class="text-[11px] text-muted-foreground">
|
||||||
|
Faces PhotoPrism detected but nobody has named yet. Naming one creates a person and tags
|
||||||
|
every matching photo.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
class="grid gap-3"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));"
|
||||||
|
>
|
||||||
|
{#each clusters as cluster (cluster.faceId)}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-2 rounded-md border border-border bg-card/30 p-3"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<img
|
||||||
|
src={thumbUrl(cluster.thumb, 'tile_224')}
|
||||||
|
alt="Unnamed face"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
class="h-20 w-20 rounded-full border border-border object-cover"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="absolute -bottom-1 -right-1 rounded-full bg-secondary px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground"
|
||||||
|
title={`${cluster.count} of your photos carry this face`}
|
||||||
|
>
|
||||||
|
{cluster.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name…"
|
||||||
|
disabled={busy[cluster.faceId]}
|
||||||
|
class="w-full rounded border border-input bg-background px-1.5 py-1 text-center text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||||
|
bind:value={drafts[cluster.faceId]}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit(cluster);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onblur={() => void submit(cluster)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
@@ -82,11 +82,13 @@
|
|||||||
() =>
|
() =>
|
||||||
patchTargets(
|
patchTargets(
|
||||||
ids,
|
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,
|
label,
|
||||||
(p) =>
|
(p) =>
|
||||||
p.TakenAt
|
p.TakenAt
|
||||||
? buildTakenAtPatch(p.TakenAt)
|
? buildTakenAtPatch(p.TakenAt, p)
|
||||||
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
||||||
),
|
),
|
||||||
label
|
label
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
Star,
|
Star,
|
||||||
Tag,
|
Tag,
|
||||||
Timer,
|
Timer,
|
||||||
|
User,
|
||||||
X
|
X
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import {
|
import {
|
||||||
@@ -44,7 +45,13 @@
|
|||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.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 { navigateToFolder, navigateToTag, setSearch, setSection } from '$lib/stores/filters.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||||
@@ -59,14 +66,19 @@
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
let basename = $state('');
|
let basename = $state('');
|
||||||
|
let title = $state('');
|
||||||
let caption = $state('');
|
let caption = $state('');
|
||||||
let takenAt = $state('');
|
let takenAt = $state('');
|
||||||
let lat = $state('');
|
let lat = $state('');
|
||||||
let lng = $state('');
|
let lng = $state('');
|
||||||
|
let altitude = $state('');
|
||||||
let country = $state('');
|
let country = $state('');
|
||||||
let keywords = $state<string[]>([]);
|
let keywords = $state<string[]>([]);
|
||||||
let keywordDraft = $state('');
|
let keywordDraft = $state('');
|
||||||
let renaming = $state(false);
|
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
|
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
|
||||||
* prefix and basename. Sidecar's rename endpoint only accepts a bare
|
* prefix and basename. Sidecar's rename endpoint only accepts a bare
|
||||||
@@ -80,16 +92,21 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const pf = primaryFile(photo);
|
const pf = primaryFile(photo);
|
||||||
basename = splitName(pf.Name ?? '').base;
|
basename = splitName(pf.Name ?? '').base;
|
||||||
|
title = photo.Title ?? '';
|
||||||
caption = photo.Caption ?? '';
|
caption = photo.Caption ?? '';
|
||||||
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
||||||
lat = photo.Lat ? String(photo.Lat) : '';
|
lat = photo.Lat ? String(photo.Lat) : '';
|
||||||
lng = photo.Lng ? String(photo.Lng) : '';
|
lng = photo.Lng ? String(photo.Lng) : '';
|
||||||
|
altitude = photo.Altitude ? String(photo.Altitude) : '';
|
||||||
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
|
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
|
||||||
const det = photo.Details ?? {};
|
const det = photo.Details ?? {};
|
||||||
keywords = (det.Keywords ?? '')
|
keywords = (det.Keywords ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((k) => k.trim())
|
.map((k) => k.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
artist = det.Artist ?? '';
|
||||||
|
copyright = det.Copyright ?? '';
|
||||||
|
license = det.License ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const patchMutation = createMutation(() => ({
|
const patchMutation = createMutation(() => ({
|
||||||
@@ -145,6 +162,11 @@
|
|||||||
if (caption === (photo.Caption ?? '')) return;
|
if (caption === (photo.Caption ?? '')) return;
|
||||||
commit({ Caption: caption, CaptionSrc: 'manual' });
|
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));
|
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
||||||
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
|
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
|
||||||
// are the photos with definitionally-untrusted dates, and showing the
|
// are the photos with definitionally-untrusted dates, and showing the
|
||||||
@@ -185,14 +207,16 @@
|
|||||||
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
|
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
|
||||||
const iso = `${takenAt}${tail}`;
|
const iso = `${takenAt}${tail}`;
|
||||||
if (iso === photo.TakenAt) return;
|
if (iso === photo.TakenAt) return;
|
||||||
commit(buildTakenAtPatch(iso));
|
commit(buildTakenAtPatch(iso, photo));
|
||||||
}
|
}
|
||||||
function commitGps() {
|
function commitGps() {
|
||||||
const nlat = parseFloat(lat);
|
const nlat = parseFloat(lat);
|
||||||
const nlng = parseFloat(lng);
|
const nlng = parseFloat(lng);
|
||||||
|
const nalt = parseFloat(altitude);
|
||||||
const patch: UpdatePhotoBody = {};
|
const patch: UpdatePhotoBody = {};
|
||||||
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
|
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
|
||||||
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
|
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);
|
if (Object.keys(patch).length) commit(patch);
|
||||||
}
|
}
|
||||||
function commitCountry() {
|
function commitCountry() {
|
||||||
@@ -202,7 +226,7 @@
|
|||||||
commit({ Country: next || 'zz', CountrySrc: 'manual' });
|
commit({ Country: next || 'zz', CountrySrc: 'manual' });
|
||||||
}
|
}
|
||||||
|
|
||||||
type DetailsKey = 'Keywords';
|
type DetailsKey = 'Keywords' | 'Artist' | 'Copyright' | 'License';
|
||||||
function commitDetails(field: DetailsKey, value: string) {
|
function commitDetails(field: DetailsKey, value: string) {
|
||||||
const prev = (photo.Details ?? {})[field] ?? '';
|
const prev = (photo.Details ?? {})[field] ?? '';
|
||||||
if (value === prev) return;
|
if (value === prev) return;
|
||||||
@@ -283,7 +307,42 @@
|
|||||||
const currentRating = $derived(photoMark.rating ?? 0);
|
const currentRating = $derived(photoMark.rating ?? 0);
|
||||||
const currentColor = $derived(photoMark.color ?? '');
|
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<string, { subjUid: string; name: string; slug: string }>();
|
||||||
|
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));
|
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 dirPath = $derived(splitName(pf.Name ?? '').dir);
|
||||||
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
|
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
|
||||||
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
||||||
@@ -508,6 +567,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="space-y-2 p-2 pt-1">
|
<div class="space-y-2 p-2 pt-1">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Title</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add a title…"
|
||||||
|
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={title}
|
||||||
|
onblur={commitTitle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
|
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -604,6 +674,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recognized people — named face markers on this photo's files.
|
||||||
|
Read-only chips linking to the person's page. -->
|
||||||
|
{#if peopleChips.length > 0}
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">People</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each peopleChips as person (person.subjUid)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px] hover:bg-accent"
|
||||||
|
onclick={() => void navigateToTag('people', person.slug)}
|
||||||
|
title={`View photos of ${person.name}`}
|
||||||
|
>
|
||||||
|
<User class="h-2.5 w-2.5 text-muted-foreground" />
|
||||||
|
{person.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
|
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
|
||||||
only: editing labels requires re-indexing on PhotoPrism's
|
only: editing labels requires re-indexing on PhotoPrism's
|
||||||
side. The dashed border + lower contrast distinguishes them
|
side. The dashed border + lower contrast distinguishes them
|
||||||
@@ -678,6 +769,62 @@
|
|||||||
onblur={commitCountry}
|
onblur={commitCountry}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[9px] text-muted-foreground">Altitude (m)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={altitude}
|
||||||
|
onblur={commitGps}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Credits — IPTC provenance fields (Artist / Copyright / License).
|
||||||
|
Closed by default; persists once opened. -->
|
||||||
|
<details
|
||||||
|
class="rounded border border-border"
|
||||||
|
open={getMetadataSectionOpen('credits', false)}
|
||||||
|
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Credits
|
||||||
|
</summary>
|
||||||
|
<div class="space-y-1.5 p-2 pt-1">
|
||||||
|
<label class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[9px] text-muted-foreground">Artist</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Photographer…"
|
||||||
|
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={artist}
|
||||||
|
onblur={() => commitDetails('Artist', artist.trim())}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[9px] text-muted-foreground">Copyright</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="© …"
|
||||||
|
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={copyright}
|
||||||
|
onblur={() => commitDetails('Copyright', copyright.trim())}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[9px] text-muted-foreground">License</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. CC BY-NC 4.0"
|
||||||
|
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={license}
|
||||||
|
onblur={() => commitDetails('License', license.trim())}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -753,6 +900,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<dt class="text-muted-foreground">Type</dt>
|
<dt class="text-muted-foreground">Type</dt>
|
||||||
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
|
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
|
||||||
|
{#if durationStr}
|
||||||
|
<dt class="text-muted-foreground">Duration</dt>
|
||||||
|
<dd class="text-foreground/80">{durationStr}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if vf?.FPS}
|
||||||
|
<dt class="text-muted-foreground">FPS</dt>
|
||||||
|
<dd class="text-foreground/80">{Math.round(vf.FPS * 10) / 10}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if vf?.Codec}
|
||||||
|
<dt class="text-muted-foreground">Codec</dt>
|
||||||
|
<dd class="text-foreground/80">{vf.Codec}</dd>
|
||||||
|
{/if}
|
||||||
<dt class="text-muted-foreground">Hash</dt>
|
<dt class="text-muted-foreground">Hash</dt>
|
||||||
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}…</dd>
|
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}…</dd>
|
||||||
<dt class="text-muted-foreground">Indexed</dt>
|
<dt class="text-muted-foreground">Indexed</dt>
|
||||||
|
|||||||
@@ -427,7 +427,7 @@
|
|||||||
title={filterText ? 'No people match the filter' : 'No people yet'}
|
title={filterText ? 'No people match the filter' : 'No people yet'}
|
||||||
description={filterText
|
description={filterText
|
||||||
? undefined
|
? undefined
|
||||||
: 'PhotoPrism creates a person whenever it clusters detected faces. Make sure face recognition is enabled and indexed.'}
|
: 'A person appears here once you name a detected face — use the "Name new faces" cards on the right.'}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
|||||||
originalName: p.OriginalName,
|
originalName: p.OriginalName,
|
||||||
path
|
path
|
||||||
});
|
});
|
||||||
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
|
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`, p));
|
||||||
}
|
}
|
||||||
await approvePhoto(id);
|
await approvePhoto(id);
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
@@ -343,6 +343,8 @@ export async function getPhoto(uid: string): Promise<PpPhoto> {
|
|||||||
*/
|
*/
|
||||||
export interface UpdatePhotoBody {
|
export interface UpdatePhotoBody {
|
||||||
OriginalName?: string;
|
OriginalName?: string;
|
||||||
|
Title?: string;
|
||||||
|
TitleSrc?: 'manual' | '';
|
||||||
Caption?: string;
|
Caption?: string;
|
||||||
CaptionSrc?: 'manual' | '';
|
CaptionSrc?: 'manual' | '';
|
||||||
Archived?: boolean;
|
Archived?: boolean;
|
||||||
@@ -373,17 +375,43 @@ export function isValidISODate(s: string): boolean {
|
|||||||
return d.toISOString().slice(0, 10) === s;
|
return d.toISOString().slice(0, 10) === s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
|
/** PhotoPrism serializes TakenAtLocal with a `Z` suffix even though it's
|
||||||
|
* semantically wall-clock time in the photo's TimeZone. Force-parse as
|
||||||
|
* UTC so offset math never picks up the *browser's* timezone. */
|
||||||
|
function parseAsUtc(s: string): number {
|
||||||
|
return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/.test(s) ? s : s + 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `photo` supplies the existing TakenAt/TakenAtLocal pair so the photo's
|
||||||
|
* UTC↔local offset survives the edit. Without it (or without a prior
|
||||||
|
* pair) local falls back to UTC — correct for TimeZone-less photos.
|
||||||
|
* Previously this forced `TakenAtLocal = UTC`, which both let PhotoPrism
|
||||||
|
* clobber manual edits when recomputing local time from TimeZone and
|
||||||
|
* shifted Year/Month/Day for photos taken far from UTC.
|
||||||
|
*/
|
||||||
|
export function buildTakenAtPatch(
|
||||||
|
iso: string,
|
||||||
|
photo?: { TakenAt?: string; TakenAtLocal?: string }
|
||||||
|
): UpdatePhotoBody {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return {};
|
if (Number.isNaN(d.getTime())) return {};
|
||||||
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
|
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
|
||||||
|
let offsetMs = 0;
|
||||||
|
if (photo?.TakenAt && photo?.TakenAtLocal) {
|
||||||
|
const a = parseAsUtc(photo.TakenAt);
|
||||||
|
const b = parseAsUtc(photo.TakenAtLocal);
|
||||||
|
if (!Number.isNaN(a) && !Number.isNaN(b)) offsetMs = b - a;
|
||||||
|
}
|
||||||
|
const local = new Date(d.getTime() + offsetMs);
|
||||||
return {
|
return {
|
||||||
TakenAt: utc,
|
TakenAt: utc,
|
||||||
TakenAtLocal: utc,
|
TakenAtLocal: local.toISOString().replace(/\.\d+Z$/, 'Z'),
|
||||||
TakenSrc: 'manual',
|
TakenSrc: 'manual',
|
||||||
Year: d.getUTCFullYear(),
|
// PhotoPrism derives Year/Month/Day from local wall-clock time.
|
||||||
Month: d.getUTCMonth() + 1,
|
Year: local.getUTCFullYear(),
|
||||||
Day: d.getUTCDate()
|
Month: local.getUTCMonth() + 1,
|
||||||
|
Day: local.getUTCDate()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,35 +722,6 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
|||||||
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasPhotosMatching(q: string): Promise<boolean> {
|
|
||||||
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
|
||||||
params: { count: 1, offset: 0, q }
|
|
||||||
});
|
|
||||||
return Array.isArray(resp.data) && resp.data.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function filterByUserPhotos<T>(
|
|
||||||
items: T[],
|
|
||||||
queryFor: (item: T) => string
|
|
||||||
): Promise<T[]> {
|
|
||||||
if (userBasePath() === '') return items;
|
|
||||||
const CONCURRENCY = 8;
|
|
||||||
const out: T[] = [];
|
|
||||||
for (let i = 0; i < items.length; i += CONCURRENCY) {
|
|
||||||
const batch = items.slice(i, i + CONCURRENCY);
|
|
||||||
const checks = await Promise.all(
|
|
||||||
batch.map(async (item) => ({
|
|
||||||
item,
|
|
||||||
has: await hasPhotosMatching(queryFor(item))
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
for (const { item, has } of checks) {
|
|
||||||
if (has) out.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listLabels(): Promise<PpLabel[]> {
|
export async function listLabels(): Promise<PpLabel[]> {
|
||||||
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
||||||
// low-confidence classifier hits, manually-removed labels). They're
|
// low-confidence classifier hits, manually-removed labels). They're
|
||||||
@@ -762,10 +761,42 @@ export interface PpSubject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listSubjects(): Promise<PpSubject[]> {
|
export async function listSubjects(): Promise<PpSubject[]> {
|
||||||
const { data } = await http.get<PpSubject[]>('/subjects', {
|
// Sidecar proxy scopes PhotoCount (and drops out-of-scope people) with
|
||||||
|
// one SQL pass, replacing the old client-side probe-per-subject filter.
|
||||||
|
const { data } = await sidecar.get<PpSubject[]>('/api/sidecar/subjects', {
|
||||||
params: { count: 1000, order: 'count' }
|
params: { count: 1000, order: 'count' }
|
||||||
});
|
});
|
||||||
return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
|
return data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Face clusters (unnamed people) ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// PhotoPrism only creates a Subject once someone names a detected face
|
||||||
|
// cluster. The sidecar lists clusters awaiting a name (scoped to the
|
||||||
|
// caller's BasePath); naming goes through PhotoPrism's own flow — a PUT
|
||||||
|
// on the cluster's representative marker — which creates the Subject and
|
||||||
|
// propagates it across the whole cluster.
|
||||||
|
|
||||||
|
export interface UnnamedFaceCluster {
|
||||||
|
faceId: string;
|
||||||
|
count: number;
|
||||||
|
/** Marker crop hash — renders via the standard thumb endpoint. */
|
||||||
|
thumb: string;
|
||||||
|
markerUid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUnnamedFaces(): Promise<UnnamedFaceCluster[]> {
|
||||||
|
const { data } = await sidecar.get<{ clusters: UnnamedFaceCluster[] }>(
|
||||||
|
'/api/sidecar/faces/unnamed'
|
||||||
|
);
|
||||||
|
return data?.clusters ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function nameFaceCluster(markerUid: string, name: string): Promise<void> {
|
||||||
|
await http.put(`/markers/${encodeURIComponent(markerUid)}`, {
|
||||||
|
Name: name,
|
||||||
|
SubjSrc: 'manual'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
||||||
|
|||||||
@@ -257,10 +257,17 @@ export function isVideo(p: PpPhoto): boolean {
|
|||||||
|
|
||||||
/** Return the Files[] entry that carries the actual video stream. Falls back
|
/** Return the Files[] entry that carries the actual video stream. Falls back
|
||||||
* to primaryFile() if no video MediaType is present (shouldn't happen for
|
* to primaryFile() if no video MediaType is present (shouldn't happen for
|
||||||
* Type === 'video' but keeps the call site total). */
|
* Type === 'video' but keeps the call site total).
|
||||||
|
*
|
||||||
|
* PhotoPrism serializes MediaType as the bare word "video" (verified
|
||||||
|
* against prod), not a MIME type — the old `startsWith('video/')` check
|
||||||
|
* never matched, so this always fell back to the JPEG poster and video
|
||||||
|
* facts (duration/codec/fps) were unreachable. */
|
||||||
export function videoFile(p: PpPhoto): PpFile {
|
export function videoFile(p: PpPhoto): PpFile {
|
||||||
const files = p.Files ?? [];
|
const files = p.Files ?? [];
|
||||||
const v = files.find((f) => f.MediaType?.startsWith('video/'));
|
const v = files.find(
|
||||||
|
(f) => f.MediaType === 'video' || f.MediaType?.startsWith('video/')
|
||||||
|
);
|
||||||
return v ?? primaryFile(p);
|
return v ?? primaryFile(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +283,34 @@ export interface PpFile {
|
|||||||
Size?: number;
|
Size?: number;
|
||||||
FileType?: string;
|
FileType?: string;
|
||||||
MediaType?: string;
|
MediaType?: string;
|
||||||
|
Codec?: string;
|
||||||
|
/** Video duration in nanoseconds (Go time.Duration serialization). */
|
||||||
|
Duration?: number;
|
||||||
|
FPS?: number;
|
||||||
|
Frames?: number;
|
||||||
|
/** EXIF orientation 1–8. */
|
||||||
|
Orientation?: number;
|
||||||
|
HDR?: boolean;
|
||||||
|
Projection?: string;
|
||||||
|
/** Face/subject markers detected in this file (present on
|
||||||
|
* GET /photos/:uid responses). */
|
||||||
|
Markers?: PpMarker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A detected region in a file — for our purposes always a face. Named
|
||||||
|
* markers carry the subject they were matched to. */
|
||||||
|
export interface PpMarker {
|
||||||
|
UID: string;
|
||||||
|
Type?: string;
|
||||||
|
Src?: string;
|
||||||
|
Name?: string;
|
||||||
|
SubjUID?: string;
|
||||||
|
SubjSrc?: string;
|
||||||
|
FaceID?: string;
|
||||||
|
Invalid?: boolean;
|
||||||
|
Score?: number;
|
||||||
|
/** Crop hash renderable via the standard thumb endpoint. */
|
||||||
|
Thumb?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PpThumbSize =
|
export type PpThumbSize =
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
import { countryName } from '$lib/utils/countries';
|
import { countryName } from '$lib/utils/countries';
|
||||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||||
|
import NewFacesPanel from '$lib/components/people/NewFacesPanel.svelte';
|
||||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
||||||
@@ -216,13 +217,28 @@
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
{#if !selectedValue}
|
{#if !selectedValue}
|
||||||
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
|
{#if category === 'people'}
|
||||||
<EmptyState
|
<!-- No person selected: surface the naming workflow instead of a
|
||||||
icon={Tag}
|
bare prompt — naming is what creates people in the first place. -->
|
||||||
title={`Pick a ${category ?? 'tag'} from the sidebar`}
|
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||||
description="Click a row in the panel on the left to filter the photo grid by that tag."
|
<NewFacesPanel />
|
||||||
/>
|
<div class="mt-8 flex items-center justify-center">
|
||||||
</main>
|
<EmptyState
|
||||||
|
icon={Tag}
|
||||||
|
title="Pick a person from the sidebar"
|
||||||
|
description="Click a row in the panel on the left to see that person's photos."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{:else}
|
||||||
|
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={Tag}
|
||||||
|
title={`Pick a ${category ?? 'tag'} from the sidebar`}
|
||||||
|
description="Click a row in the panel on the left to filter the photo grid by that tag."
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
|||||||
Reference in New Issue
Block a user