diff --git a/sidecar/handlers_heap.go b/sidecar/handlers_heap.go index b1fbbe6..9eb57ee 100644 --- a/sidecar/handlers_heap.go +++ b/sidecar/handlers_heap.go @@ -90,9 +90,10 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"}) return } - // Pull the heap's photos via the q=album:UID query. count=1000 covers - // every realistic heap; merged=true expands stacked variants so we - // move the JPG/HEIC sibling alongside the primary. + // Pull the heap's membership via the q=album:UID query (count=1000 + // covers every realistic heap). We only need the UID list here — the + // search's Files array is trimmed and drops videos, so we re-resolve + // each photo's full file set below via resolvePhotosFull. q := url.QueryEscape("album:" + albumUID) listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true" resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil) @@ -104,17 +105,31 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc { c.JSON(resp.Status, gin.H{"error": "list photos failed"}) return } - var photos []heapPhoto - if err := json.Unmarshal(resp.Body, &photos); err != nil { + var listed []heapPhoto + if err := json.Unmarshal(resp.Body, &listed); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"}) return } + uids := make([]string, 0, len(listed)) + for _, p := range listed { + uids = append(uids, p.UID) + } + + // Re-fetch each photo's complete file list so videos (and other multi- + // file photos) move whole — the album search alone would orphan the + // .mov. See resolvePhotosFull. + photos, resolveErrs, err := resolvePhotosFull(c.Request.Context(), pp, token, uids) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + errs = append(resolveErrs, errs...) heapDeleted := false if deleteHeap { diff --git a/sidecar/handlers_move.go b/sidecar/handlers_move.go index 02b6d44..e138171 100644 --- a/sidecar/handlers_move.go +++ b/sidecar/handlers_move.go @@ -1,13 +1,13 @@ package main import ( + "context" "encoding/json" "log/slog" "net/http" "net/url" "os" "path/filepath" - "strings" "github.com/gin-gonic/gin" ) @@ -57,32 +57,24 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc { return } - // Resolve the photos via a single q=uid:a|b|c query. PhotoPrism's - // search treats `|` as OR within a filter value, so one round-trip - // covers the whole selection; merged=true pulls stacked variants so - // the JPG/HEIC sibling travels with its primary. - q := url.QueryEscape("uid:" + strings.Join(body.UIDs, "|")) - listURL := "/api/v1/photos?q=" + q + "&count=" + itoa(len(body.UIDs)) + "&merged=true" - resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil) + // Resolve each photo's FULL file list via the single-photo endpoint + // rather than the /photos search (see resolvePhotosFull) — the search + // drops a photo's video file from its trimmed Files array and filters + // videos out by quality/review, so the .mov never gets listed to move. + photos, resolveErrs, err := resolvePhotosFull(c.Request.Context(), pp, token, body.UIDs) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } - if !resp.OK { - c.JSON(resp.Status, gin.H{"error": "list photos failed"}) - return - } - var photos []heapPhoto - if err := json.Unmarshal(resp.Body, &photos); err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"}) - return - } moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + // Surface UIDs PhotoPrism couldn't resolve alongside any per-file + // errors so the client's "N skipped" summary stays accurate. + errs = append(resolveErrs, errs...) slog.Info("photos.move", "requested", len(body.UIDs), @@ -99,6 +91,36 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc { } } +// resolvePhotosFull fetches each photo's complete file list via the +// single-photo endpoint (GET /photos/:uid). Use this instead of the /photos +// search whenever you need every file of a photo: the search — even with +// merged=true — can return a trimmed Files array that omits the photo's video +// file, and it applies PhotoPrism's default quality/review/archive filters. +// Both silently drop videos (which PhotoPrism routinely files under review) +// from a move. The per-UID lookup returns every file and ignores those +// filters. UIDs PhotoPrism can't resolve are returned in `errs` so the batch +// continues; a transport-level failure aborts with a fatal error. Mirrors +// handleRename's single-photo resolution. +func resolvePhotosFull(ctx context.Context, pp *ppClient, token string, uids []string) (photos []heapPhoto, errs []heapErr, err error) { + photos = make([]heapPhoto, 0, len(uids)) + for _, uid := range uids { + resp, e := pp.call(ctx, http.MethodGet, "/api/v1/photos/"+url.PathEscape(uid), token, nil) + if e != nil { + return nil, nil, e + } + if !resp.OK { + errs = append(errs, heapErr{UID: uid, Reason: "photo not found"}) + continue + } + var p heapPhoto + if e := json.Unmarshal(resp.Body, &p); e != nil { + return nil, nil, e + } + photos = append(photos, p) + } + return photos, errs, nil +} + type folderMoveBody struct { // Originals-relative destination parent. ""/"/"/"." mean the root. TargetParent string `json:"targetParent"`