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

View File

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