From 7c27e9d5a3ffbffca0b91eb2bbb6856ebf8cc341 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 23:57:56 -0400 Subject: [PATCH] fix(scanner): copy created_at once per target when albums merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-pair attribute copy let a later merged old album overwrite the target's created_at with its own, shifting the album's position in Recently Added depending on processing order. Split the copy: created_at stays under the per-target guard (copied once), while the cover fields copy per old→new pair so any merged old album can still contribute an uploaded cover. --- scanner/phase_2_missing_tracks.go | 15 +++++++++++---- scanner/phase_2_missing_tracks_test.go | 7 +++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index 251bc6942..fc049b054 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -315,6 +315,13 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err) } + // Keep created_at once per target, so moved albums don't resurface in "Recently Added" + if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil { + if !errors.Is(err, model.ErrNotFound) { + log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err) + } + } + // Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here p.processedAlbumAnnotations[newAlbumID] = true } @@ -323,8 +330,8 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID) } - // Copy created_at/cover per old→new pair (not per target): with several old albums - // merging into one, any of them may hold the uploaded cover; empty sources never copy. + // Copy the cover per old→new pair (not per target): with several old albums + // merging into one, any of them may hold it; empty sources never copy. pairKey := oldAlbumID + "\x00" + newAlbumID p.annotationMutex.RLock() copyDone := p.processedAlbumCopies[pairKey] @@ -332,9 +339,9 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error if !copyDone { p.annotationMutex.Lock() if !p.processedAlbumCopies[pairKey] { - if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at", "uploaded_image", "cover_art_updated_at"); err != nil { + if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "uploaded_image", "cover_art_updated_at"); err != nil { if !errors.Is(err, model.ErrNotFound) { - log.Warn(p.ctx, "Scanner: Could not copy album attributes", "from", oldAlbumID, "to", newAlbumID, err) + log.Warn(p.ctx, "Scanner: Could not copy album cover", "from", oldAlbumID, "to", newAlbumID, err) } } p.processedAlbumCopies[pairKey] = true diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index c23b84d16..638aa05a8 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -874,9 +874,10 @@ var _ = Describe("phaseMissingTracks", func() { missing2 := model.MediaFile{ID: "mg-2", PID: "MG2", Path: "lib1/b.mp3", AlbumID: "old-2", LibraryID: 1} matched2 := model.MediaFile{ID: "mt-2", PID: "MG2", Path: "lib2/b.mp3", AlbumID: "new-album", LibraryID: 1} + firstTime := time.Date(2018, 3, 1, 0, 0, 0, 0, time.UTC) albumRepo.SetData(model.Albums{ - {ID: "old-1", LibraryID: 1}, - {ID: "old-2", LibraryID: 1, UploadedImage: "old-2_cover.jpg"}, + {ID: "old-1", LibraryID: 1, CreatedAt: firstTime}, + {ID: "old-2", LibraryID: 1, UploadedImage: "old-2_cover.jpg", CreatedAt: time.Date(2022, 9, 9, 0, 0, 0, 0, time.UTC)}, {ID: "new-album", LibraryID: 1}, }) @@ -890,6 +891,8 @@ var _ = Describe("phaseMissingTracks", func() { newAlbum, err := albumRepo.Get("new-album") Expect(err).ToNot(HaveOccurred()) Expect(newAlbum.UploadedImage).To(Equal("old-2_cover.jpg")) + // created_at copies once per target: the second merged album must not overwrite it + Expect(newAlbum.CreatedAt).To(Equal(firstTime)) }) It("should not copy album created_at when album ID does not change", func() {