diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 860a80bce..a2d7e8639 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -491,3 +491,10 @@ func Register(name string, init Constructor) { } constructors[name] = init } + +// IsBuiltinScrobbler reports whether name belongs to a registered builtin +// scrobbler (e.g. "lastfm", "listenbrainz"). +func IsBuiltinScrobbler(name string) bool { + _, ok := constructors[name] + return ok +} diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index b5a478c2a..382abb811 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -104,6 +104,13 @@ var _ = Describe("PlayTracker", func() { Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled")) }) + Describe("IsBuiltinScrobbler", func() { + It("reports whether the name belongs to a registered builtin scrobbler", func() { + Expect(IsBuiltinScrobbler("fake")).To(BeTrue()) + Expect(IsBuiltinScrobbler("some-plugin")).To(BeFalse()) + }) + }) + Describe("GetNowPlaying", func() { It("returns current playing music", func() { track2 := track diff --git a/model/scrobble_buffer.go b/model/scrobble_buffer.go index c75a82853..43ee2cc01 100644 --- a/model/scrobble_buffer.go +++ b/model/scrobble_buffer.go @@ -20,4 +20,5 @@ type ScrobbleBufferRepository interface { Next(service string, userId string) (*ScrobbleEntry, error) Dequeue(entry *ScrobbleEntry) error Length() (int64, error) + Discard(service string) error } diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go index 3cfb836bf..cf54c664a 100644 --- a/persistence/scrobble_buffer_repository.go +++ b/persistence/scrobble_buffer_repository.go @@ -93,6 +93,10 @@ func (r *scrobbleBufferRepository) Dequeue(entry *model.ScrobbleEntry) error { return r.delete(Eq{"id": entry.ID}) } +func (r *scrobbleBufferRepository) Discard(service string) error { + return r.delete(Eq{"service": service}) +} + func (r *scrobbleBufferRepository) Length() (int64, error) { return r.count(Select()) } diff --git a/persistence/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go index edf59ce49..3aa71070e 100644 --- a/persistence/scrobble_buffer_repository_test.go +++ b/persistence/scrobble_buffer_repository_test.go @@ -191,6 +191,28 @@ var _ = Describe("ScrobbleBufferRepository", func() { }) + Describe("Discard", func() { + It("deletes all entries for a service, keeping other services intact", func() { + Expect(scrobble.Discard("a")).To(Succeed()) + + count, err := scrobble.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + + entry, err := scrobble.Next("b", "2222") + Expect(err).ToNot(HaveOccurred()) + Expect(entry).ToNot(BeNil()) + }) + + It("is a no-op for a service without entries", func() { + Expect(scrobble.Discard("nonexistent")).To(Succeed()) + + count, err := scrobble.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(4))) + }) + }) + Describe("UserIds", func() { It("should return ordered list for services", func() { ids, err := scrobble.UserIDs("a") diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 23d904309..2119f1a5a 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -107,6 +108,16 @@ func (m *Manager) removePluginFromDB(ctx context.Context, repo model.PluginRepos if err := repo.Delete(pluginID); err != nil { return fmt.Errorf("deleting plugin from DB: %w", err) } + // Discard any scrobbles still buffered for the removed plugin, so they are + // not delivered to an unrelated plugin that reuses the same name later. + // Skip names owned by builtin scrobblers: buffer entries are keyed by + // service name, so removing a plugin file named e.g. "lastfm.ndp" must not + // wipe the builtin Last.fm retry queue. + if scrobbler.IsBuiltinScrobbler(pluginID) { + log.Debug(ctx, "Keeping buffered scrobbles: name is owned by a builtin scrobbler", "plugin", pluginID) + } else if err := m.ds.ScrobbleBuffer(ctx).Discard(pluginID); err != nil { + log.Error(ctx, "Error discarding buffered scrobbles for removed plugin", "plugin", pluginID, err) + } log.Info(ctx, "Plugin removed", "plugin", pluginID) m.sendPluginRefreshEvent(ctx, events.Any) return nil diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go index e2adebfad..26da2079b 100644 --- a/plugins/manager_sync_test.go +++ b/plugins/manager_sync_test.go @@ -1,12 +1,67 @@ package plugins import ( + "context" "path/filepath" + "time" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("removePluginFromDB", func() { + It("discards buffered scrobbles for the removed plugin", func() { + ctx := context.Background() + buffer := tests.CreateMockedScrobbleBufferRepo() + Expect(buffer.Enqueue("my-plugin", "user1", "track1", time.Now())).To(Succeed()) + Expect(buffer.Enqueue("other-plugin", "user1", "track2", time.Now())).To(Succeed()) + + repo := tests.CreateMockPluginRepo() + plugin := model.Plugin{ID: "my-plugin", Enabled: false} + repo.SetData(model.Plugins{plugin}) + + // No broker: sendPluginRefreshEvent is nil-safe, and testBroker is + // defined in manager_test.go, which is excluded on Windows. + m := &Manager{ + ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer}, + } + Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) + + _, err := repo.Get("my-plugin") + Expect(err).To(MatchError(model.ErrNotFound)) + + remaining, err := buffer.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(remaining).To(Equal(int64(1))) + entry, err := buffer.Next("other-plugin", "user1") + Expect(err).ToNot(HaveOccurred()) + Expect(entry).ToNot(BeNil(), "entries of other services must be kept") + }) + + It("keeps buffered scrobbles of a builtin scrobbler sharing the removed plugin's name", func() { + ctx := context.Background() + scrobbler.Register("builtin-svc", func(model.DataStore) scrobbler.Scrobbler { return nil }) + buffer := tests.CreateMockedScrobbleBufferRepo() + Expect(buffer.Enqueue("builtin-svc", "user1", "track1", time.Now())).To(Succeed()) + + repo := tests.CreateMockPluginRepo() + plugin := model.Plugin{ID: "builtin-svc", Enabled: false} + repo.SetData(model.Plugins{plugin}) + + m := &Manager{ + ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer}, + } + Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) + + remaining, err := buffer.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(remaining).To(Equal(int64(1)), "builtin scrobbler queue must not be wiped") + }) +}) + var _ = Describe("ComputeFileSHA256", func() { It("returns a consistent 64-char lowercase hex hash for the same file", func() { dir := GinkgoT().TempDir() diff --git a/tests/mock_scrobble_buffer_repo.go b/tests/mock_scrobble_buffer_repo.go index 5865f423a..2eb5e8a93 100644 --- a/tests/mock_scrobble_buffer_repo.go +++ b/tests/mock_scrobble_buffer_repo.go @@ -83,6 +83,22 @@ func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error { return nil } +func (m *MockedScrobbleBufferRepo) Discard(service string) error { + if m.Error != nil { + return m.Error + } + m.mu.Lock() + defer m.mu.Unlock() + newData := model.ScrobbleEntries{} + for _, e := range m.Data { + if e.Service != service { + newData = append(newData, e) + } + } + m.Data = newData + return nil +} + func (m *MockedScrobbleBufferRepo) Length() (int64, error) { if m.Error != nil { return 0, m.Error