Mulimage 2.0 #1

Merged
dtoro merged 64 commits from new into main 2026-05-21 22:48:55 +02:00
2 changed files with 45 additions and 34 deletions
Showing only changes of commit d2a76fa58c - Show all commits

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
@@ -183,16 +184,18 @@ type folderCountsRow struct {
// `/photos?count=1000` per folder from the browser (≈1 MB JSON per
// folder × N folders) to populate the left-sidebar tree. Moving the
// fan-out into the sidecar keeps the same correctness profile — same
// q-DSL, same `merged=false` UID dedupe, same 1000-row server cap —
// but the wire payload back to the browser collapses to a single small
// JSON object (`{path: count}`).
// q-DSL, same `merged=false` UID dedupe — but the wire payload back
// to the browser collapses to a single small JSON object
// (`{path: count}`).
//
// We bounce off PhotoPrism with `count=1000` and dedupe UIDs server-
// side rather than trusting a count header: PhotoPrism's `/photos`
// X-Count is the *per-page* row count (per existing front-end
// comment), not the total-match count, so we'd silently undercount if
// we used it. Lifting the 1000 cap would mean either iterating offsets
// or growing PhotoPrism's response cap — both out of scope here.
// We bounce off PhotoPrism with paginated `count=1000` calls and dedupe
// UIDs server-side rather than trusting a count header: PhotoPrism's
// `/photos` X-Count is the *per-page* row count (per existing front-end
// comment), not the total-match count, so we'd silently undercount any
// folder with more than 1000 files. The loop walks offsets until PP
// returns a short page, so the result is correct regardless of folder
// size (until the wider fan-out becomes the bottleneck, which is many
// orders of magnitude away on this hardware).
//
// Bounded concurrency caps the fan-out so a library with hundreds of
// folders doesn't open hundreds of connections to PhotoPrism at once.
@@ -245,31 +248,39 @@ func handleFolderCounts(pp *ppClient) gin.HandlerFunc {
// UID — which is what the old client-side code did, and
// what we keep doing here.
q := url.QueryEscape(`path:"` + path + `*"`)
resp, err := pp.call(c.Request.Context(), http.MethodGet,
"/api/v1/photos?count=1000&offset=0&merged=false&q="+q, token, nil)
if err != nil || !resp.OK {
slog.Warn("folder.counts: pp call failed",
"path", path,
"err", err,
"status", func() int {
if resp != nil {
return resp.Status
}
return 0
}())
return
}
var rows []folderCountsRow
if err := json.Unmarshal(resp.Body, &rows); err != nil {
slog.Warn("folder.counts: parse failed", "path", path, "err", err)
return
}
seen := make(map[string]struct{}, len(rows))
for _, r := range rows {
if r.UID == "" {
continue
const pageSize = 1000
seen := make(map[string]struct{})
for offset := 0; ; offset += pageSize {
resp, err := pp.call(c.Request.Context(), http.MethodGet,
fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=false&q=%s", pageSize, offset, q),
token, nil)
if err != nil || !resp.OK {
slog.Warn("folder.counts: pp call failed",
"path", path,
"offset", offset,
"err", err,
"status", func() int {
if resp != nil {
return resp.Status
}
return 0
}())
return
}
var rows []folderCountsRow
if err := json.Unmarshal(resp.Body, &rows); err != nil {
slog.Warn("folder.counts: parse failed", "path", path, "offset", offset, "err", err)
return
}
for _, r := range rows {
if r.UID == "" {
continue
}
seen[r.UID] = struct{}{}
}
if len(rows) < pageSize {
break
}
seen[r.UID] = struct{}{}
}
mu.Lock()
counts[path] = len(seen)

View File

@@ -143,7 +143,7 @@
// BasePath shape and never fall back to the category-count.
const labelsCountQuery = createQuery<number>(() => ({
queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser],
queryFn: () => countPhotos(scoped('all:true label:*')),
queryFn: () => countPhotos(scoped('label:*')),
enabled: isAuthenticated(),
staleTime: 60_000
}));