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