From d2a76fa58c8c00a00b7379fb775e00fd594f0727 Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 19 May 2026 22:44:43 +0000 Subject: [PATCH] fix(sidebar): paginate folder counts; drop bogus all:true from labels query Two distinct bugs were causing left-sidebar badges to under-report: 1. sidecar/folders/counts hard-capped each PP /photos call at count=1000 and deduped UIDs from that single page. Any folder with >1000 file rows under it (typical for a multi-year root scan with HEIC sidecars) silently lost everything past row 1000. On this library the root badge reported 912 while the year subfolders summed to 1175. Loop offsets instead, breaking when PP returns a short page. 2. The Labels-badge query passed all:true label:* to PP, which 400s with "Unable to do that" - none of the other bucket queries prefix all:true. Drop it; the scoped() helper already injects the user's path clause when applicable. Co-Authored-By: Claude Opus 4.7 (1M context) --- sidecar/handlers_folders.go | 77 +++++++++++-------- .../lib/components/layout/LeftSidebar.svelte | 2 +- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/sidecar/handlers_folders.go b/sidecar/handlers_folders.go index d5a7e09..ca0fba7 100644 --- a/sidecar/handlers_folders.go +++ b/sidecar/handlers_folders.go @@ -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) diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index 5bb1547..975ac7d 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -143,7 +143,7 @@ // BasePath shape and never fall back to the category-count. const labelsCountQuery = createQuery(() => ({ queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser], - queryFn: () => countPhotos(scoped('all:true label:*')), + queryFn: () => countPhotos(scoped('label:*')), enabled: isAuthenticated(), staleTime: 60_000 }));