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:
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user