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. resolveUnderRoot ensures the target lives // inside ORIGINALS_ROOT and that its parent is a real directory. // Empty / "/" / "." are valid here — they mean "drop these into // originals/ itself" (the modal's "Root" option). resolveUnderRoot // rejects those for safety, so handle the root case explicitly. var targetAbs string trimmed := strings.Trim(body.TargetFolder, "/") if trimmed == "" || trimmed == "." { targetAbs = cfg.OriginalsRoot } else { abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"}) return } targetAbs = abs } destAbs := targetAbs if subfolder != "" { destAbs = filepath.Join(targetAbs, subfolder) if err := os.MkdirAll(destAbs, 0o755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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 } sourceParents := map[string]struct{}{} errs := []heapErr{} moved, copied := 0, 0 for _, photo := range photos { // Pick the file to physically move. PhotoPrism's "primary" file // for a HEIC photo is the generated `.HEIC.jpg` preview that // lives in storage/sidecar (Root=="sidecar"), not in originals // — moving that path would fail "file missing on disk" every // time. Prefer the primary that lives in originals (Root=="/") // and fall back to the first originals-rooted file. PhotoPrism // regenerates sidecars on reindex, so they don't need to follow. var file ppFile found := false for _, f := range photo.Files { if f.Root == "/" && f.Primary { file, found = f, true break } } if !found { for _, f := range photo.Files { if f.Root == "/" { file, found = f, true break } } } if !found { errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"}) continue } srcRel := file.Name srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel) if !sameOrUnder(srcAbs, cfg.OriginalsRoot) { errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"}) continue } st, err := os.Stat(srcAbs) if err != nil || !st.Mode().IsRegular() { errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"}) continue } if filepath.Dir(srcAbs) == destAbs { errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"}) continue } _, name, ok := uniqueName(destAbs, filepath.Base(srcAbs)) if !ok { errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"}) continue } dstAbs := filepath.Join(destAbs, name) if mode == "move" { if err := os.Rename(srcAbs, dstAbs); err != 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 { errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()}) continue } if err2 := os.Remove(srcAbs); err2 != nil { errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()}) continue } } moved++ } else { if err := copyFile(srcAbs, dstAbs); err != nil { errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()}) continue } copied++ } sourceParents[filepath.Dir(srcRel)] = struct{}{} } // 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 — callers (the frontend's // invalidateQueries refetch in particular) need 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 // without surprising the server. 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) } 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, }) } }