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) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 22:44:43 +00:00
parent 0d5f380948
commit d2a76fa58c
2 changed files with 45 additions and 34 deletions

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,31 +248,39 @@ 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 + `*"`)
resp, err := pp.call(c.Request.Context(), http.MethodGet, const pageSize = 1000
"/api/v1/photos?count=1000&offset=0&merged=false&q="+q, token, nil) seen := make(map[string]struct{})
if err != nil || !resp.OK { for offset := 0; ; offset += pageSize {
slog.Warn("folder.counts: pp call failed", resp, err := pp.call(c.Request.Context(), http.MethodGet,
"path", path, fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=false&q=%s", pageSize, offset, q),
"err", err, token, nil)
"status", func() int { if err != nil || !resp.OK {
if resp != nil { slog.Warn("folder.counts: pp call failed",
return resp.Status "path", path,
} "offset", offset,
return 0 "err", err,
}()) "status", func() int {
return if resp != nil {
} return resp.Status
var rows []folderCountsRow }
if err := json.Unmarshal(resp.Body, &rows); err != nil { return 0
slog.Warn("folder.counts: parse failed", "path", path, "err", err) }())
return return
} }
seen := make(map[string]struct{}, len(rows)) var rows []folderCountsRow
for _, r := range rows { if err := json.Unmarshal(resp.Body, &rows); err != nil {
if r.UID == "" { slog.Warn("folder.counts: parse failed", "path", path, "offset", offset, "err", err)
continue return
}
for _, r := range rows {
if r.UID == "" {
continue
}
seen[r.UID] = struct{}{}
}
if len(rows) < pageSize {
break
} }
seen[r.UID] = struct{}{}
} }
mu.Lock() mu.Lock()
counts[path] = len(seen) counts[path] = len(seen)

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
})); }));