diff --git a/sidecar/fs.go b/sidecar/fs.go index f307208..4c5a9d7 100644 --- a/sidecar/fs.go +++ b/sidecar/fs.go @@ -108,6 +108,37 @@ func uniqueName(destDir, basename string) (abs, name string, ok bool) { return "", "", false } +// uniqueStem finds a base name (extension stripped) that is free for *every* +// extension in `exts` under destDir, appending `-1`, `-2`, … on collision — +// the multi-file analogue of uniqueName. Moving a photo's originals siblings +// (e.g. IMG_1234.JPG + IMG_1234.MOV) under a single shared stem keeps +// PhotoPrism stacking them as one photo after reindex; picking the stem once +// for the whole group is what stops the video from being orphaned under a +// differently-suffixed name than its poster. Caps at 1000 attempts to match +// uniqueName. The passed extensions keep their on-disk case (we compare +// case-sensitively via os.Stat, which is correct on the case-sensitive +// volumes PhotoPrism targets). +func uniqueStem(destDir, primaryBase string, exts []string) (stem string, ok bool) { + base := strings.TrimSuffix(primaryBase, filepath.Ext(primaryBase)) + for i := 0; i < 1000; i++ { + candidate := base + if i > 0 { + candidate = base + "-" + itoa(i) + } + free := true + for _, ext := range exts { + if _, err := os.Stat(filepath.Join(destDir, candidate+ext)); !errors.Is(err, os.ErrNotExist) { + free = false + break + } + } + if free { + return candidate, true + } + } + return "", false +} + // itoa is the tiny stdlib-free formatter we use inside hot loops. func itoa(n int) string { if n == 0 { diff --git a/sidecar/handlers_heap.go b/sidecar/handlers_heap.go index 1cc7127..b1fbbe6 100644 --- a/sidecar/handlers_heap.go +++ b/sidecar/handlers_heap.go @@ -165,77 +165,124 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, errs = []heapErr{} 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 + // 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 == "/" && f.Primary { - file, found = f, true - break + if f.Root != "/" { + continue + } + group = append(group, f) + if f.Primary && !havePrimary { + primary, havePrimary = f, true } } - if !found { - for _, f := range photo.Files { - if f.Root == "/" { - file, found = f, true - break - } - } - } - if !found { + if len(group) == 0 { 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 + if !havePrimary { + primary = group[0] } - st, statErr := os.Stat(srcAbs) - if statErr != nil || !st.Mode().IsRegular() { - errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"}) - continue + + // 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) + } } - if filepath.Dir(srcAbs) == destAbs { - errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"}) - continue - } - _, name, ok := uniqueName(destAbs, filepath.Base(srcAbs)) + stem, ok := uniqueStem(destAbs, filepath.Base(primary.Name), exts) if !ok { errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"}) continue } - 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 { - errs = append(errs, heapErr{UID: photo.UID, Reason: mvErr.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 - } + + // 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 } - moved++ - } else { - if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil { - errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.Error()}) + 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++ } - sourceParents[filepath.Dir(srcRel)] = struct{}{} } // Reindex the destination + every source parent so PhotoPrism's DB