From 3e164c48d0c7f198dd43d6d215162a200bef8554 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 8 Jun 2026 00:43:56 +0200 Subject: [PATCH] fix(notes): page PhotoPrism server-side so /notes shows every captioned photo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side paging of listPhotosWithNotes stopped early for BasePath users: the sidecar post-filters each page by BasePath, so a full upstream page can arrive short, tripping the `length < PAGE` end condition before the library is exhausted — hiding notes past the first slice. Add GET /api/sidecar/notes: the sidecar pages /api/v1/photos to completion (keying the loop off the raw upstream page length), filters to non-empty Caption under the caller's BasePath, dedupes by UID, and returns the set. listPhotosWithNotes now calls this single endpoint. Co-Authored-By: Claude Opus 4.8 --- sidecar/handlers_photos.go | 70 +++++++++++++++++++++++++++++- sidecar/main.go | 4 ++ web/src/lib/services/photoprism.ts | 27 ++++-------- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/sidecar/handlers_photos.go b/sidecar/handlers_photos.go index 5d393a5..ae3cbe3 100644 --- a/sidecar/handlers_photos.go +++ b/sidecar/handlers_photos.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "net/http" "strings" @@ -70,4 +71,71 @@ func handlePhotos(pp *ppClient) gin.HandlerFunc { c.Header("X-Count", itoa(len(filtered))) c.JSON(http.StatusOK, filtered) } -} \ No newline at end of file +} + +// handleNotes pages PhotoPrism's photo list to completion and returns only +// photos carrying a non-empty Caption (mule-image's "Note"), scoped to the +// caller's BasePath. Paging server-side is what makes this correct: the +// client can't tell when the *BasePath-filtered* list is exhausted (a full +// upstream page can filter down to a short — or empty — slice), but here we +// can key the loop off the raw upstream page length. +// +// Route: GET /api/sidecar/notes (behind requireSession) +func handleNotes(pp *ppClient) gin.HandlerFunc { + return func(c *gin.Context) { + token := ctxToken(c) + basePath := ctxBasePath(c) + prefix := basePath + "/" + + const pageSize = 1000 + out := make([]map[string]any, 0, 64) + seen := make(map[string]struct{}) + + for offset := 0; ; offset += pageSize { + path := fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=true&order=newest", pageSize, offset) + resp, err := pp.call(c.Request.Context(), http.MethodGet, path, token, nil) + if err != nil || !resp.OK { + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"}) + return + } + + var photos []map[string]any + if err := json.Unmarshal(resp.Body, &photos); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected photos response"}) + return + } + rawLen := len(photos) + + for _, ph := range photos { + // BasePath scope — same rule as handlePhotos. + if basePath != "" { + pathStr, _ := ph["FileName"].(string) + if pathStr != basePath && !strings.HasPrefix(pathStr, prefix) { + continue + } + } + // Non-empty caption only. + caption, _ := ph["Caption"].(string) + if strings.TrimSpace(caption) == "" { + continue + } + // Dedupe by UID — `merged` can still repeat a photo at a page seam. + uid, _ := ph["UID"].(string) + if uid != "" { + if _, ok := seen[uid]; ok { + continue + } + seen[uid] = struct{}{} + } + out = append(out, ph) + } + + // A short upstream page means PhotoPrism has no more rows. + if rawLen < pageSize { + break + } + } + + c.JSON(http.StatusOK, out) + } +} diff --git a/sidecar/main.go b/sidecar/main.go index c533653..8852742 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -108,6 +108,10 @@ func main() { // tabs only show photos the user owns. auth.GET("/timeline", handlePhotos(pp)) + // Photos carrying a Note (Caption) — pages PhotoPrism fully so + // the /notes view isn't capped to the newest slice. + auth.GET("/notes", handleNotes(pp)) + // User-scoped folders — post-filters the folder tree by BasePath // so the sidebar shows only folders under the user's library root. auth.GET("/folders", handleFoldersProxy(pp)) diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts index 4a21ef6..0c37775 100644 --- a/web/src/lib/services/photoprism.ts +++ b/web/src/lib/services/photoprism.ts @@ -639,25 +639,16 @@ export interface PhotoWithNote { } export async function listPhotosWithNotes(): Promise { - // PhotoPrism has no "caption is not empty" search filter, so we page the - // whole library and keep the captioned rows. Paging (rather than a single - // count:1000 fetch) means notes on photos older than the newest 1000 still - // surface — the previous cap silently hid them. + // The sidecar pages PhotoPrism to completion server-side and returns only + // captioned, BasePath-scoped photos — paging client-side would stop early + // because each page is BasePath-filtered before we see it (a full upstream + // page can arrive short), silently hiding notes past the first slice. + const { data } = await sidecar.get('/api/sidecar/notes'); const out: PhotoWithNote[] = []; - const seen = new Set(); - const PAGE = 1000; - for (let offset = 0; ; offset += PAGE) { - const list = await listPhotos({ count: PAGE, offset, order: 'newest', merged: true }); - for (const p of list) { - // `merged: true` can repeat a photo across file-rows; dedupe by UID - // so the same tile doesn't render twice. - if (seen.has(p.UID)) continue; - seen.add(p.UID); - const note = p.Caption?.trim(); - if (!note) continue; - out.push({ photo: p, note }); - } - if (list.length < PAGE) break; + for (const p of data) { + const note = p.Caption?.trim(); + if (!note) continue; + out.push({ photo: p, note }); } return out; }