Files
mule-image/sidecar/handlers_folders.go
dtoro d2a76fa58c fix(sidebar): paginate folder counts; drop bogus all:true from labels query
Two distinct bugs were causing left-sidebar badges to under-report:

1. sidecar/folders/counts hard-capped each PP /photos call at count=1000
   and deduped UIDs from that single page. Any folder with >1000 file
   rows under it (typical for a multi-year root scan with HEIC sidecars)
   silently lost everything past row 1000. On this library the root
   badge reported 912 while the year subfolders summed to 1175. Loop
   offsets instead, breaking when PP returns a short page.

2. The Labels-badge query passed all:true label:* to PP, which 400s with
   "Unable to do that" - none of the other bucket queries prefix
   all:true. Drop it; the scoped() helper already injects the user's
   path clause when applicable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:44:43 +00:00

308 lines
9.7 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"
"fmt"
"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 — but the wire payload back
// to the browser collapses to a single small JSON object
// (`{path: count}`).
//
// 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.
// 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:<x>` is an exact match in PhotoPrism's q-DSL —
// it matches only photos whose `photo_path` field equals
// <x>, not descendants. The indexer always nests photos
// under YYYY/MM, so an internal tree node like `2024` has
// zero direct children and reports a count of 0 unless we
// recurse. `path:<x>*` is the documented wildcard form and
// matches both `<x>` itself (no harm if empty) and every
// `<x>/...` descendant.
//
// `merged=false` still 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 + `*"`)
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
}
}
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)
}
}