The move resolved photos via the /photos search, whose merged Files array is trimmed (often omitting a photo's video file) and which applies PhotoPrism's quality/review/archive filters — so a video's .mov was never listed to move and nothing happened. Resolve each UID via GET /photos/:uid instead (full file list, no filters), shared by photos-move and heap-convert via resolvePhotosFull. Unresolved UIDs are reported as skipped rather than aborting the batch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
197 lines
6.4 KiB
Go
197 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type photosMoveBody struct {
|
|
UIDs []string `json:"uids"`
|
|
TargetFolder string `json:"targetFolder"`
|
|
Mode string `json:"mode"` // "move" or "copy"
|
|
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
|
|
}
|
|
|
|
// handlePhotosMove moves/copies an arbitrary list of photos (by UID) into a
|
|
// folder under originals/. Mirrors handleHeapConvert but resolves the photos
|
|
// from a UID list instead of an album query, then shares movePhotoFiles for
|
|
// the on-disk work + reindex. Backs the grid's "Move to folder" action.
|
|
func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
token := ctxToken(c)
|
|
|
|
var body photosMoveBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
|
return
|
|
}
|
|
if len(body.UIDs) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no uids"})
|
|
return
|
|
}
|
|
mode := body.Mode
|
|
if mode != "copy" {
|
|
mode = "move"
|
|
}
|
|
|
|
var subfolder string
|
|
if body.Subfolder != "" {
|
|
s, ok := sanitizeFilename(body.Subfolder)
|
|
if !ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
|
|
return
|
|
}
|
|
subfolder = s
|
|
}
|
|
|
|
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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),
|
|
"mode", mode,
|
|
"moved", moved,
|
|
"copied", copied,
|
|
"errors", len(errs),
|
|
)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"moved": moved,
|
|
"copied": copied,
|
|
"errors": errs,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// handleFolderMove reparents a folder: moves the directory (and everything in
|
|
// it) under a different parent, keeping its own name. Mirrors
|
|
// handleFolderRename but the destination is a parent folder rather than a new
|
|
// name. A whole-tree os.Rename preserves subfolder structure.
|
|
func handleFolderMove(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 folderMoveBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
|
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
|
|
}
|
|
targetParentAbs, err := resolveMoveTarget(cfg, body.TargetParent)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
|
|
return
|
|
}
|
|
// Can't move a folder into itself or one of its own descendants.
|
|
if sameOrUnder(targetParentAbs, oldAbs) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
|
|
return
|
|
}
|
|
newAbs := filepath.Join(targetParentAbs, filepath.Base(oldAbs))
|
|
if newAbs == oldAbs {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "already in that folder"})
|
|
return
|
|
}
|
|
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
|
|
return
|
|
}
|
|
if _, err := os.Stat(newAbs); err == nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
|
|
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.move", "from", oldRel, "to", newRel)
|
|
// Reindex both the old and new parents so PhotoPrism drops the moved
|
|
// rows from the source view and picks them up under the destination.
|
|
fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
|
|
fireReindex(cfg, pp, token, "/"+filepath.Dir(newRel))
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"ok": true,
|
|
"oldPath": oldRel,
|
|
"newPath": newRel,
|
|
})
|
|
}
|
|
}
|