fix(scanner): copy created_at once per target when albums merge

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.
This commit is contained in:
Deluan 2026-07-17 23:57:56 -04:00
parent f4c1431bdf
commit 7c27e9d5a3
2 changed files with 16 additions and 6 deletions

View File

@ -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

View File

@ -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() {