perf(web): batch folder counts, bounded scroll scan, adaptive thumbs, lazy preview

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:46:10 +02:00
parent 9a3ad3e579
commit 6b8c7abc20
10 changed files with 300 additions and 49 deletions

View File

@@ -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;

View File

@@ -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))

View File

@@ -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
}