fix(persistence): move scrobbles and buffered scrobbles off a discarded media file

Both tables carry ON DELETE CASCADE on media_file_id, so 'missing fix'
deleting the target erased its play history and dropped scrobbles still
waiting on an external service. scrobble_buffer needs OR IGNORE for its
unique (user_id, service, media_file_id, play_time).
This commit is contained in:
Deluan 2026-09-12 10:35:38 -04:00
parent 9f216f624a
commit f5996711f4
2 changed files with 35 additions and 0 deletions

View File

@ -411,6 +411,15 @@ func (r *mediaFileRepository) ReassignReferences(prevID, newID string) error {
if _, err := r.executeSQL(upd); err != nil {
return fmt.Errorf("reassigning playlist tracks: %w", err)
}
upd = Update("scrobbles").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID})
if _, err := r.executeSQL(upd); err != nil {
return fmt.Errorf("reassigning scrobbles: %w", err)
}
// OR IGNORE: scrobble_buffer is unique on (user_id, service, media_file_id, play_time)
buf := Expr("update or ignore scrobble_buffer set media_file_id = ? where media_file_id = ?", newID, prevID)
if _, err := r.executeSQL(buf); err != nil {
return fmt.Errorf("reassigning buffered scrobbles: %w", err)
}
return nil
}

View File

@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
@ -941,6 +942,8 @@ var _ = Describe("MediaRepository", func() {
_ = mr.Delete(next.ID)
_, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}}))
_, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("bookmark").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}}))
_, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("scrobbles").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}}))
_, _ = mr.(*mediaFileRepository).executeSQL(squirrel.Delete("scrobble_buffer").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}}))
})
It("moves annotations, bookmarks and playlist entries onto the new id", func() {
@ -963,6 +966,29 @@ var _ = Describe("MediaRepository", func() {
Expect(withTracks.Tracks[0].MediaFileID).To(Equal(next.ID))
})
It("moves scrobbles and buffered scrobbles onto the new id", func() {
ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"})
scrobbles := NewScrobbleRepository(ctx, GetDBXBuilder())
buffer := NewScrobbleBufferRepository(ctx, GetDBXBuilder())
Expect(scrobbles.RecordScrobble(prev.ID, time.Now())).To(Succeed())
Expect(buffer.Enqueue("lastfm", "userid", prev.ID, time.Now())).To(Succeed())
Expect(mr.ReassignReferences(prev.ID, next.ID)).To(Succeed())
Expect(mr.Delete(prev.ID)).To(Succeed())
all, err := scrobbles.GetAll()
Expect(err).ToNot(HaveOccurred())
mine := slice.Map(slice.Filter(all, func(sc model.Scrobble) bool {
return sc.MediaFileID == prev.ID || sc.MediaFileID == next.ID
}), func(sc model.Scrobble) string { return sc.MediaFileID })
Expect(mine).To(ConsistOf(next.ID))
entry, err := buffer.Next("lastfm", "userid")
Expect(err).ToNot(HaveOccurred())
Expect(entry).ToNot(BeNil())
Expect(entry.MediaFile.ID).To(Equal(next.ID))
})
It("keeps the new id's own annotation and bookmark when both exist", func() {
Expect(mr.SetRating(5, prev.ID)).To(Succeed())
Expect(mr.SetRating(1, next.ID)).To(Succeed())