From 6b8c7abc202f0032af5cdcde8d9c16746d2f94e7 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 17 May 2026 22:46:10 +0200 Subject: [PATCH] perf(web): batch folder counts, bounded scroll scan, adaptive thumbs, lazy preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six-item frontend performance pass on the SvelteKit app. P1 — Move per-folder photo counts to a new sidecar endpoint and defer the fetch to requestIdleCallback. The old client-side path fired one /photos?count=1000 per folder from the browser (≈1 MB JSON × N folders) on every cold sidebar mount; the new POST /api/sidecar/folders/counts fans out over loopback with bounded concurrency and returns a single {path: count} payload of a few KB. P2 — Bound the visibleRange scroll-scan around the previous visible band instead of sweeping every shell from index 0 on each scroll-rAF. Falls back to a full sweep on cache miss (filter reset, programmatic jump) so behaviour is unchanged at the edges. P3 — Adaptive thumbnail size + srcset. PhotoTile now picks the smallest PhotoPrism tile_* variant (100/224/500) that covers the user's grid preset at the current DPR. Adds decoding="async". P4 — Lift the selection check above the {#each} loop. Mostly readability — SvelteSet.has() is already per-key reactive — but keeps the hot loop body terse. P5 — Split dedupedAll / photos derivations so filter-store mutations (search-as-you-type, section toggles) don't re-walk every loaded page; only the cheap folder-scope filter re-runs. P6 — Dynamic-import PreviewOverlay on first preview.uid !== null and cache the loaded module; closing the overlay leaves the component mounted with its internal {#if} collapsing the DOM. Co-Authored-By: Claude Opus 4.7 (1M context) --- sidecar/handlers_folders.go | 110 ++++++++++++++++++ sidecar/main.go | 1 + sidecar/pp.go | 6 +- web/src/lib/actions/visibleRange.ts | 47 ++++++-- .../lib/components/layout/LeftSidebar.svelte | 29 ++++- .../lib/components/timeline/PhotoTile.svelte | 15 ++- web/src/lib/services/photoprism.ts | 43 ++++--- web/src/lib/stores/session.svelte.ts | 49 ++++++++ web/src/routes/+layout.svelte | 24 +++- web/src/routes/+page.svelte | 25 +++- 10 files changed, 300 insertions(+), 49 deletions(-) diff --git a/sidecar/handlers_folders.go b/sidecar/handlers_folders.go index 9a35600..cd68660 100644 --- a/sidecar/handlers_folders.go +++ b/sidecar/handlers_folders.go @@ -2,12 +2,14 @@ package main import ( "context" + "encoding/json" "errors" "log/slog" "net/http" "net/url" "os" "path/filepath" + "sync" "github.com/gin-gonic/gin" ) @@ -163,6 +165,114 @@ func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc { } } +type folderCountsBody struct { + Paths []string `json:"paths"` +} + +// folderCountsRow is the minimal PhotoPrism photo projection the handler +// needs — just UID, so dedupe-by-UID survives `merged=false` (which +// expands one photo into one row per File on disk). PhotoPrism returns a +// JSON array of much richer objects; unmarshalling into this small +// shape ignores everything we don't care about. +type folderCountsRow struct { + UID string `json:"UID"` +} + +// handleFolderCounts returns photo counts for each PhotoPrism folder +// path in one round-trip. The web client used to fire one +// `/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}`). +// +// 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. +// +// Bounded concurrency caps the fan-out so a library with hundreds of +// folders doesn't open hundreds of connections to PhotoPrism at once. +// Errors per-folder degrade to count=0 rather than failing the whole +// batch — the sidebar would rather show a missing badge for one folder +// than nothing for any. +func handleFolderCounts(pp *ppClient) gin.HandlerFunc { + return func(c *gin.Context) { + token := ctxToken(c) + var body folderCountsBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) + return + } + if len(body.Paths) == 0 { + c.JSON(http.StatusOK, gin.H{}) + return + } + + const maxInFlight = 8 + var ( + wg sync.WaitGroup + sem = make(chan struct{}, maxInFlight) + mu sync.Mutex + counts = make(map[string]int, len(body.Paths)) + ) + // Seed every input key so the response always carries the same + // shape the client posted, even for paths whose lookup failed. + for _, p := range body.Paths { + counts[p] = 0 + } + for _, p := range body.Paths { + path := p + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + // `path:` is non-recursive in PhotoPrism's q-DSL: matches + // direct children only. `merged=false` returns one row per + // File on disk, so HEIC + companion JPG count twice unless + // we dedupe by 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 + } + seen[r.UID] = struct{}{} + } + mu.Lock() + counts[path] = len(seen) + mu.Unlock() + }() + } + wg.Wait() + c.JSON(http.StatusOK, counts) + } +} + // fireReindex wraps pp.reindex with logging and a detached context so // it can run in a goroutine after the response has gone out. The Node // prototype kicks reindex with `void reindex(...)` and never awaits; diff --git a/sidecar/main.go b/sidecar/main.go index dd8c286..e0114f5 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -70,6 +70,7 @@ func main() { auth.POST("/files/:uid/rename", handleRename(cfg, pp)) auth.POST("/folders", handleFolderCreate(cfg, pp)) + auth.POST("/folders/counts", handleFolderCounts(pp)) auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp)) auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp)) diff --git a/sidecar/pp.go b/sidecar/pp.go index e8a3298..19caf4c 100644 --- a/sidecar/pp.go +++ b/sidecar/pp.go @@ -28,11 +28,14 @@ func newPPClient(base string) *ppClient { // ppResp is the trimmed projection of an HTTP response that callers // actually consume. Status + raw body are exposed so handlers can mirror -// PhotoPrism's status code or parse the body themselves. +// PhotoPrism's status code or parse the body themselves. Header is +// retained for callers that need `X-Count` / `X-Limit` / `X-Offset` on +// list endpoints — PhotoPrism exposes total-match counts there. type ppResp struct { OK bool Status int Body []byte + Header http.Header } // call issues an authenticated request against PhotoPrism. body is @@ -78,6 +81,7 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body OK: resp.StatusCode >= 200 && resp.StatusCode < 300, Status: resp.StatusCode, Body: buf, + Header: resp.Header, }, nil } diff --git a/web/src/lib/actions/visibleRange.ts b/web/src/lib/actions/visibleRange.ts index 81d27ff..7a0998d 100644 --- a/web/src/lib/actions/visibleRange.ts +++ b/web/src/lib/actions/visibleRange.ts @@ -63,24 +63,51 @@ export function visibleRange(node: HTMLElement, params: VisibleRangeParams) { let lastLast = -1; let rafId: number | null = null; - function compute() { - rafId = null; - const shells = node.querySelectorAll('[data-uid-shell]'); - if (shells.length === 0) return; - const rootRect = node.getBoundingClientRect(); - // Sweep through shells (rendered in document order = photo order) - // and find the first/last whose rect crosses the viewport. Bail - // out the moment we pass the bottom edge — shells past the - // viewport can't intersect, no point measuring them. + // Between frames the visible band can shift by at most ~one viewport + // of shells (any further and it's a programmatic jump, which falls + // back to a full sweep below). 300 covers a fast-flick on the densest + // thumbnail preset (XS) plus a buffer; tightening it further saves + // little and risks missing the new band after a quick scroll-wheel + // flick. + const SCAN_MARGIN = 300; + + function scanFrom( + shells: NodeListOf, + rootRect: DOMRect, + start: number + ): [number, number] { let first = -1; let last = -1; - for (let i = 0; i < shells.length; i++) { + for (let i = start; i < shells.length; i++) { const r = shells[i].getBoundingClientRect(); if (r.bottom < rootRect.top) continue; if (r.top > rootRect.bottom) break; if (first === -1) first = i; last = i; } + return [first, last]; + } + + function compute() { + rafId = null; + const shells = node.querySelectorAll('[data-uid-shell]'); + if (shells.length === 0) return; + const rootRect = node.getBoundingClientRect(); + // Anchor the sweep around the previous result so a deep timeline + // doesn't pay `getBoundingClientRect()` × (every-shell-above-the- + // viewport) on every scroll tick. Previous loop scanned from 0 + // each time → quadratic-feeling on long sessions with 1000+ + // loaded photos. + const startHint = lastFirst >= 0 ? Math.max(0, lastFirst - SCAN_MARGIN) : 0; + let [first, last] = scanFrom(shells, rootRect, startHint); + // Bounded scan missed the band — user scrolled past the hint + // margin (programmatic jump, filter-reset reflow, etc.). Fall + // back to a single full sweep to re-anchor. Costs the same as + // the old behavior on this one frame, then bounded scans take + // over again. + if (first === -1 && startHint > 0) { + [first, last] = scanFrom(shells, rootRect, 0); + } if (first === -1 || last === -1) return; if (first === lastFirst && last === lastLast) return; lastFirst = first; diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index b2afa87..6596bec 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -127,16 +127,33 @@ ); // Per-folder photo counts. PhotoPrism's /folders/originals reports - // FileCount: 0 for every folder, so we hit /photos?q=path:X per folder - // in parallel. Key the query off the folder-path list so it refetches - // when folders are added/renamed/deleted, and share the ['photos', …] - // prefix so it invalidates alongside the other photo caches whenever a - // mutation lands. + // FileCount: 0 for every folder, so the sidecar /folders/counts + // endpoint resolves them in one round-trip (see listFolderCounts). + // Key the query off the folder-path list so it refetches when folders + // are added/renamed/deleted, and share the ['photos', …] prefix so it + // invalidates alongside the other photo caches whenever a mutation + // lands. + // + // `countsReady` gates the query until just after the sidebar's first + // paint. Even though the sidecar response is small, the per-folder + // fan-out it does to PhotoPrism still takes a few hundred ms cold; + // blocking it on idle means the folder list paints immediately and + // the count badges fade in instead of holding back the whole tree. const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path)); + let countsReady = $state(false); + if (browser) { + const kick = () => (countsReady = true); + // requestIdleCallback isn't in Safari yet; fall back to a short + // timeout so the deferral is still bounded. + const ric = (window as Window & { requestIdleCallback?: (cb: () => void) => number }) + .requestIdleCallback; + if (typeof ric === 'function') ric(kick); + else setTimeout(kick, 200); + } const folderCountsQuery = createQuery>(() => ({ queryKey: ['photos', 'folder-counts', [...folderPaths].sort()], queryFn: () => listFolderCounts(folderPaths), - enabled: isAuthenticated() && folderPaths.length > 0, + enabled: isAuthenticated() && folderPaths.length > 0 && countsReady, staleTime: 60_000 })); const folderCounts = $derived(folderCountsQuery.data ?? {}); diff --git a/web/src/lib/components/timeline/PhotoTile.svelte b/web/src/lib/components/timeline/PhotoTile.svelte index 5753777..b518a96 100644 --- a/web/src/lib/components/timeline/PhotoTile.svelte +++ b/web/src/lib/components/timeline/PhotoTile.svelte @@ -16,7 +16,8 @@ -->