fix(move): move every originals file of a photo, not just the primary
Videos, Live Photos, and RAW+JPG pairs keep several files under Root "/". The old movePhotoFiles moved only the primary (often the poster JPG), orphaning the .mov: PhotoPrism then saw the photo as moved (dropped from the grid) while the video stayed behind and broke. Move the whole originals group under one shared stem (new uniqueStem helper) so siblings re-stack after reindex; fail the photo and report it if any sibling can't move. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -165,53 +165,87 @@ 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 `<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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -219,23 +253,36 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
|
||||
// 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
|
||||
failure = mvErr.Error()
|
||||
break
|
||||
}
|
||||
if err2 := os.Remove(srcAbs); err2 != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
|
||||
continue
|
||||
failure = "rename ok, source remove failed: " + err2.Error()
|
||||
break
|
||||
}
|
||||
}
|
||||
moved++
|
||||
} else {
|
||||
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.Error()})
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user