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 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. 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 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 } 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 `.JPG`, `.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) }