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>
339 lines
10 KiB
Go
339 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type heapConvertBody struct {
|
|
TargetFolder string `json:"targetFolder"`
|
|
Mode string `json:"mode"` // "move" or "copy"
|
|
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
|
|
DeleteHeap bool `json:"deleteHeap"`
|
|
}
|
|
|
|
type heapPhoto struct {
|
|
UID string `json:"UID"`
|
|
Files []ppFile `json:"Files"`
|
|
}
|
|
|
|
type heapErr struct {
|
|
UID string `json:"uid"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// copyFile is the os.Rename fallback for cross-device moves and the
|
|
// primary path for "copy" mode. Streams so a 4GB video doesn't pin
|
|
// memory; preserves mode bits, sets the modification time to now (we're
|
|
// creating a new inode either way).
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
st, err := in.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
os.Remove(dst)
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
token := ctxToken(c)
|
|
albumUID := c.Param("uid")
|
|
|
|
var body heapConvertBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
|
return
|
|
}
|
|
mode := body.Mode
|
|
if mode != "copy" {
|
|
mode = "move"
|
|
}
|
|
deleteHeap := mode == "move" && body.DeleteHeap
|
|
|
|
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
|
|
}
|
|
|
|
// Resolve destination under ORIGINALS_ROOT. Empty / "/" / "." mean
|
|
// "drop these into originals/ itself" (the modal's "Root" option).
|
|
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
|
return
|
|
}
|
|
// 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)
|
|
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 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 {
|
|
r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil)
|
|
if err == nil && r.OK {
|
|
heapDeleted = true
|
|
} else if err != nil {
|
|
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()})
|
|
} else {
|
|
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)})
|
|
}
|
|
}
|
|
|
|
slog.Info("heap.convert",
|
|
"album", albumUID,
|
|
"mode", mode,
|
|
"moved", moved,
|
|
"copied", copied,
|
|
"errors", len(errs),
|
|
"heap_deleted", heapDeleted,
|
|
)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"moved": moved,
|
|
"copied": copied,
|
|
"errors": errs,
|
|
"heap_deleted": heapDeleted,
|
|
})
|
|
}
|
|
}
|
|
|
|
// movePhotoFiles moves (or copies) each photo's originals-rooted primary file
|
|
// into targetAbs — optionally into `subfolder` under it — then blocks on a
|
|
// PhotoPrism reindex of the destination plus every source parent so the next
|
|
// /photos fetch reflects the move. Shared by handleHeapConvert (album-scoped)
|
|
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
|
|
// but move them identically. Returns per-photo errors in `errs`; the returned
|
|
// top-level error is only for a fatal precondition (subfolder mkdir failed).
|
|
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode string) (moved, copied int, errs []heapErr, err error) {
|
|
destAbs := targetAbs
|
|
if subfolder != "" {
|
|
destAbs = filepath.Join(targetAbs, subfolder)
|
|
if e := os.MkdirAll(destAbs, 0o755); e != nil {
|
|
return 0, 0, nil, e
|
|
}
|
|
}
|
|
|
|
sourceParents := map[string]struct{}{}
|
|
errs = []heapErr{}
|
|
|
|
for _, photo := range photos {
|
|
// Gather *every* originals-rooted file of the photo, not just the
|
|
// primary. A video, Live Photo, or RAW+JPG pair keeps several files
|
|
// under Root "/" (e.g. the poster IMG.JPG and its IMG.MOV), and they
|
|
// must travel together — moving only the primary orphans the rest, so
|
|
// the photo looks "moved" in PhotoPrism (the poster defines its path)
|
|
// while the actual video is left behind and silently breaks. Sidecar-
|
|
// rooted files (Root=="sidecar": HEIC previews, .json) are regenerated
|
|
// on reindex and intentionally skipped. Pick the stem from the primary
|
|
// (or the first originals file) so the siblings re-stack under one name.
|
|
var group []ppFile
|
|
var primary ppFile
|
|
havePrimary := false
|
|
for _, f := range photo.Files {
|
|
if f.Root != "/" {
|
|
continue
|
|
}
|
|
group = append(group, f)
|
|
if f.Primary && !havePrimary {
|
|
primary, havePrimary = f, true
|
|
}
|
|
}
|
|
if len(group) == 0 {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
|
continue
|
|
}
|
|
if !havePrimary {
|
|
primary = group[0]
|
|
}
|
|
|
|
// Choose one collision-free stem for the whole group up front, so the
|
|
// siblings land as `<stem>.JPG`, `<stem>.MOV`, … and stay stacked.
|
|
exts := make([]string, 0, len(group))
|
|
extSeen := map[string]struct{}{}
|
|
for _, f := range group {
|
|
ext := filepath.Ext(f.Name)
|
|
if _, dup := extSeen[ext]; !dup {
|
|
extSeen[ext] = struct{}{}
|
|
exts = append(exts, ext)
|
|
}
|
|
}
|
|
stem, ok := uniqueStem(destAbs, filepath.Base(primary.Name), exts)
|
|
if !ok {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
|
continue
|
|
}
|
|
|
|
// Move/copy each sibling. A failure on any one fails the whole photo
|
|
// (surfaced in errs) rather than leaving a half-moved stack unreported.
|
|
var failure string
|
|
movedAny := false
|
|
usedNames := map[string]struct{}{}
|
|
for _, f := range group {
|
|
srcRel := f.Name
|
|
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
|
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
|
|
failure = "path escapes originals"
|
|
break
|
|
}
|
|
st, statErr := os.Stat(srcAbs)
|
|
if statErr != nil || !st.Mode().IsRegular() {
|
|
failure = "file missing on disk"
|
|
break
|
|
}
|
|
if filepath.Dir(srcAbs) == destAbs {
|
|
// Already in the target folder — nothing to do for this sibling,
|
|
// but the photo isn't an error just because one file is in place.
|
|
continue
|
|
}
|
|
name := stem + filepath.Ext(srcAbs)
|
|
// Two originals files sharing an extension (rare) would collide on
|
|
// the shared stem; keep the extra one's own unique name so neither
|
|
// overwrites the other.
|
|
if _, clash := usedNames[name]; clash {
|
|
_, n, uok := uniqueName(destAbs, filepath.Base(srcAbs))
|
|
if !uok {
|
|
failure = "too many collisions"
|
|
break
|
|
}
|
|
name = n
|
|
}
|
|
usedNames[name] = struct{}{}
|
|
dstAbs := filepath.Join(destAbs, name)
|
|
if mode == "move" {
|
|
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
|
|
// Cross-device renames fail with EXDEV — fall back to
|
|
// copy+remove so a library that spans filesystems still
|
|
// works.
|
|
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
|
failure = mvErr.Error()
|
|
break
|
|
}
|
|
if err2 := os.Remove(srcAbs); err2 != nil {
|
|
failure = "rename ok, source remove failed: " + err2.Error()
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
|
failure = cpErr.Error()
|
|
break
|
|
}
|
|
}
|
|
movedAny = true
|
|
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
|
}
|
|
if failure != "" {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: failure})
|
|
continue
|
|
}
|
|
if !movedAny {
|
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
|
continue
|
|
}
|
|
if mode == "move" {
|
|
moved++
|
|
} else {
|
|
copied++
|
|
}
|
|
}
|
|
|
|
// Reindex the destination + every source parent so PhotoPrism's DB
|
|
// catches up. We block on these so the response only goes out after the
|
|
// index reflects the move — the frontend's invalidateQueries refetch
|
|
// needs the next /photos fetch to return the moved files, otherwise the
|
|
// folder view looks unchanged. PhotoPrism's index endpoint serialises
|
|
// calls internally; running them sequentially matches that contract.
|
|
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
|
paths := map[string]struct{}{destRel: {}}
|
|
for p := range sourceParents {
|
|
paths[p] = struct{}{}
|
|
}
|
|
if subfolder != "" {
|
|
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
|
|
paths[parent] = struct{}{}
|
|
}
|
|
for p := range paths {
|
|
reindex := "/"
|
|
if p != "" && p != "." {
|
|
reindex = "/" + p
|
|
}
|
|
fireReindex(cfg, pp, token, reindex)
|
|
}
|
|
|
|
return moved, copied, errs, nil
|
|
}
|
|
|
|
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."
|
|
// mean the Originals root itself) into a validated absolute path under the
|
|
// root. Shared by the heap-convert and photos-move destination handling.
|
|
func resolveMoveTarget(cfg *Config, targetFolder string) (string, error) {
|
|
trimmed := strings.Trim(targetFolder, "/")
|
|
if trimmed == "" || trimmed == "." {
|
|
return cfg.OriginalsRoot, nil
|
|
}
|
|
return resolveUnderRoot(cfg.OriginalsRoot, targetFolder, true)
|
|
}
|