Files
mule-image/sidecar/handlers_folders.go
dtoro 6b8c7abc20 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>
2026-05-17 22:48:59 +02:00

289 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
"github.com/gin-gonic/gin"
)
// pathParam pulls the URL-encoded :rel out of the Gin context and
// unescapes it. UseRawPath is on at the router level (see main.go) so the
// raw value still carries `%2F` for nested paths; we decode here.
func pathParam(c *gin.Context, key string) (string, bool) {
raw := c.Param(key)
if raw == "" {
return "", false
}
dec, err := url.PathUnescape(raw)
if err != nil {
return "", false
}
return dec, true
}
type folderCreateBody struct {
Path string `json:"path"`
}
func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body folderCreateBody
if err := c.ShouldBindJSON(&body); err != nil || body.Path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
return
}
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.Path, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
if _, err := os.Stat(abs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
return
} else if !errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := os.Mkdir(abs, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rel, _ := filepath.Rel(cfg.OriginalsRoot, abs)
slog.Info("folder.create", "path", rel)
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
}
}
type folderRenameBody struct {
NewName string `json:"newName"`
}
func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
rel, ok := pathParam(c, "rel")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
var body folderRenameBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
newName, ok := sanitizeFilename(body.NewName)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain dirname"})
return
}
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
st, err := os.Stat(oldAbs)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
if _, err := os.Stat(newAbs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
return
} else if !errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
return
}
if err := os.Rename(oldAbs, newAbs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
slog.Info("folder.rename", "from", oldRel, "to", newRel)
go fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
c.JSON(http.StatusOK, gin.H{
"ok": true,
"oldPath": oldRel,
"newPath": newRel,
})
}
}
func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
rel, ok := pathParam(c, "rel")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
if abs == cfg.OriginalsRoot {
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
return
}
st, err := os.Stat(abs)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
entries, err := os.ReadDir(abs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(entries) > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "directory not empty"})
return
}
if err := os.Remove(abs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
slog.Info("folder.delete", "path", rel)
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
}
}
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;
// matching that here keeps the apparent latency of mutating endpoints
// low (PhotoPrism's index can take seconds on a big folder).
func fireReindex(_ *Config, pp *ppClient, token, parentRel string) {
// pp.call's client already enforces a 60s timeout, so the parent
// context can be detached from the request — the handler has long
// since written its response.
if err := pp.reindex(context.Background(), token, parentRel); err != nil {
slog.Warn("reindex failed", "path", parentRel, "err", err)
}
}