From db0a130dd2227768567ec39149a9c79972c2b81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20Winkelk=C3=B6tter?= Date: Wed, 19 Aug 2026 23:20:56 +0000 Subject: [PATCH 1/8] feat(lastfm): added config for minimum scrobble duration (#5986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timo Winkelkötter --- adapters/lastfm/agent.go | 2 +- adapters/lastfm/agent_test.go | 22 ++++++++++++++++++++++ conf/configuration.go | 8 ++++++++ conf/configuration_test.go | 26 ++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index f967595e3..48864d574 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -383,7 +383,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S return errors.Join(err, scrobbler.ErrNotAuthorized) } - if s.Duration <= 30 { + if s.Duration <= float32(conf.Server.LastFM.MinScrobbleDuration.Seconds()) { log.Debug(ctx, "Skipping Last.fm scrobble for short song", "track", s.Title, "duration", s.Duration) return nil } diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 94024b9ab..c61b9ed18 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -472,6 +472,28 @@ var _ = Describe("lastfmAgent", func() { Expect(httpClient.SavedRequest).To(BeNil()) }) + It("skips songs according to the configured minimum duration", func() { + conf.Server.LastFM.MinScrobbleDuration = 60 * time.Second + track.Duration = 59 + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("does not skip short songs when the minimum duration is disabled", func() { + conf.Server.LastFM.MinScrobbleDuration = 0 + track.Duration = 29 + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest).ToNot(BeNil()) + }) + It("returns ErrNotAuthorized if user is not linked", func() { err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) diff --git a/conf/configuration.go b/conf/configuration.go index fbbaaf252..961741d0d 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -208,6 +208,7 @@ type lastfmOptions struct { Secret string //nolint:gosec Language string ScrobbleFirstArtistOnly bool + MinScrobbleDuration time.Duration // Computed values Languages []string `conf:"-"` // Computed from Language, split by comma @@ -484,6 +485,12 @@ func Load(noConfigDump bool) { Server.UICoverArtSize = newValue } + // Check for negative values in durations and clamp to zero (TODO: more than just LastFM.MinScrobbleduration) + if Server.LastFM.MinScrobbleDuration < 0 { + log.Warn("MinScrobbleDuration must be non-negative, clamping", "value", Server.LastFM.MinScrobbleDuration, "newValue", 0) + Server.LastFM.MinScrobbleDuration = 0 + } + // Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back. imgSize, _ := humanize.ParseBytes(Server.MaxImageSize) uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize) @@ -1050,6 +1057,7 @@ func setViperDefaults() { viper.SetDefault("lastfm.apikey", "") viper.SetDefault("lastfm.secret", "") viper.SetDefault("lastfm.scrobblefirstartistonly", false) + viper.SetDefault("lastfm.minscrobbleduration", 30*time.Second) viper.SetDefault("deezer.enabled", true) viper.SetDefault("deezer.language", consts.DefaultInfoLanguage) viper.SetDefault("listenbrainz.enabled", true) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index ac6477572..849ff1257 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -383,6 +384,31 @@ var _ = Describe("Configuration", func() { }) }) + Describe("MinScrobbleDuration", func() { + It("defaults to 30 seconds", func() { + conf.Load(true) + Expect(conf.Server.LastFM.MinScrobbleDuration).To(Equal(30 * time.Second)) + }) + + It("clamps negative values to zero", func() { + viper.SetDefault("lastfm.minscrobbleduration", "-10s") + conf.Load(true) + Expect(conf.Server.LastFM.MinScrobbleDuration).To(Equal(time.Duration(0))) + }) + + It("disables with zero value", func() { + viper.SetDefault("lastfm.minscrobbleduration", "0s") + conf.Load(true) + Expect(conf.Server.LastFM.MinScrobbleDuration).To(Equal(time.Duration(0))) + }) + + It("preserves positive values", func() { + viper.SetDefault("lastfm.minscrobbleduration", "45s") + conf.Load(true) + Expect(conf.Server.LastFM.MinScrobbleDuration).To(Equal(45 * time.Second)) + }) + }) + Describe("EnforceNonRootUser", func() { It("defaults to false", func() { conf.Load(true) From 6519c815efc85025edfde8b7ea4d3c3a151c9cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20Winkelk=C3=B6tter?= Date: Thu, 20 Aug 2026 00:18:48 +0000 Subject: [PATCH 2/8] fix(lastfm): restore default for negative duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timo Winkelkötter --- conf/configuration.go | 8 ++++---- conf/configuration_test.go | 5 +++-- consts/consts.go | 2 ++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 961741d0d..b9c0d3408 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -485,10 +485,10 @@ func Load(noConfigDump bool) { Server.UICoverArtSize = newValue } - // Check for negative values in durations and clamp to zero (TODO: more than just LastFM.MinScrobbleduration) + // Check for negative values in durations and set to default (TODO: more than just LastFM.MinScrobbleduration) if Server.LastFM.MinScrobbleDuration < 0 { - log.Warn("MinScrobbleDuration must be non-negative, clamping", "value", Server.LastFM.MinScrobbleDuration, "newValue", 0) - Server.LastFM.MinScrobbleDuration = 0 + log.Warn("MinScrobbleDuration must be non-negative, setting to default", "value", Server.LastFM.MinScrobbleDuration, "newValue", consts.DefaultMinScrobbleDuration) + Server.LastFM.MinScrobbleDuration = consts.DefaultMinScrobbleDuration } // Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back. @@ -1057,7 +1057,7 @@ func setViperDefaults() { viper.SetDefault("lastfm.apikey", "") viper.SetDefault("lastfm.secret", "") viper.SetDefault("lastfm.scrobblefirstartistonly", false) - viper.SetDefault("lastfm.minscrobbleduration", 30*time.Second) + viper.SetDefault("lastfm.minscrobbleduration", consts.DefaultMinScrobbleDuration) viper.SetDefault("deezer.enabled", true) viper.SetDefault("deezer.language", consts.DefaultInfoLanguage) viper.SetDefault("listenbrainz.enabled", true) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 849ff1257..189ee58ca 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -390,10 +391,10 @@ var _ = Describe("Configuration", func() { Expect(conf.Server.LastFM.MinScrobbleDuration).To(Equal(30 * time.Second)) }) - It("clamps negative values to zero", func() { + It("sets negative values to default", func() { viper.SetDefault("lastfm.minscrobbleduration", "-10s") conf.Load(true) - Expect(conf.Server.LastFM.MinScrobbleDuration).To(Equal(time.Duration(0))) + Expect(conf.Server.LastFM.MinScrobbleDuration).To(Equal(consts.DefaultMinScrobbleDuration)) }) It("disables with zero value", func() { diff --git a/consts/consts.go b/consts/consts.go index aed8ecf66..5c661d6c1 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -79,6 +79,8 @@ const ( DefaultInfoLanguage = "en" + DefaultMinScrobbleDuration = 30 * time.Second + ArtistInfoTimeToLive = 24 * time.Hour AlbumInfoTimeToLive = 7 * 24 * time.Hour UpdateLastAccessFrequency = time.Minute From 46e480aa25a61072894dfb70db4c46fa12420f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20Winkelk=C3=B6tter?= Date: Thu, 20 Aug 2026 09:49:35 +0000 Subject: [PATCH 3/8] fix(lastfm): changed min-scrobble test for configuration to be dependent on the constant in consts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timo Winkelkötter --- conf/configuration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 189ee58ca..ed11f4afa 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -388,7 +388,7 @@ var _ = Describe("Configuration", func() { Describe("MinScrobbleDuration", func() { It("defaults to 30 seconds", func() { conf.Load(true) - Expect(conf.Server.LastFM.MinScrobbleDuration).To(Equal(30 * time.Second)) + Expect(conf.Server.LastFM.MinScrobbleDuration).To(Equal(consts.DefaultMinScrobbleDuration)) }) It("sets negative values to default", func() { From bb1a3edbad9bb32c3b146f6e065454c907fc6513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20Winkelk=C3=B6tter?= Date: Fri, 21 Aug 2026 10:54:12 +0000 Subject: [PATCH 4/8] fix(lastfm): only send duration to LastFM if song is above 30s, otherwise omit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timo Winkelkötter --- adapters/lastfm/client.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/adapters/lastfm/client.go b/adapters/lastfm/client.go index 726df1360..3d8990ecc 100644 --- a/adapters/lastfm/client.go +++ b/adapters/lastfm/client.go @@ -171,7 +171,11 @@ func (c *client) scrobble(ctx context.Context, sessionKey string, info ScrobbleI params.Add("album", info.album) params.Add("trackNumber", strconv.Itoa(info.trackNumber)) params.Add("mbid", info.mbid) - params.Add("duration", strconv.Itoa(info.duration)) + if info.duration >= 30 { + params.Add("duration", strconv.Itoa(info.duration)) + } else { + log.Debug(ctx, "LastFM: scrobble duration is less than 30 seconds, duration will not be transmitted", "duration", info.duration) + } params.Add("albumArtist", info.albumArtist) params.Add("sk", sessionKey) resp, err := c.makeRequest(ctx, http.MethodPost, params, true) From 2cbae9ce6360918efddaf43bf6ee184e413faadb Mon Sep 17 00:00:00 2001 From: Timo <101946596+timonet7@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:59:36 +0200 Subject: [PATCH 5/8] Change duration check from >= to > 30 Changed to reflect the AI suggestion. --- adapters/lastfm/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adapters/lastfm/client.go b/adapters/lastfm/client.go index 3126061ec..d7fdd7915 100644 --- a/adapters/lastfm/client.go +++ b/adapters/lastfm/client.go @@ -176,7 +176,7 @@ func (c *client) scrobble(ctx context.Context, sessionKey string, info ScrobbleI params.Add("album", info.album) params.Add("trackNumber", strconv.Itoa(info.trackNumber)) params.Add("mbid", info.mbid) - if info.duration >= 30 { + if info.duration > 30 { params.Add("duration", strconv.Itoa(info.duration)) } else { log.Debug(ctx, "LastFM: scrobble duration is less than 30 seconds, duration will not be transmitted", "duration", info.duration) From 0dcc709743785e718a1199e5c58306e1719d9ec4 Mon Sep 17 00:00:00 2001 From: Timo <101946596+timonet7@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:32:47 +0200 Subject: [PATCH 6/8] Fixed my oversight: Added missing brace I missed a brace and added it to end the if statement correctly. --- conf/configuration.go | 1 + 1 file changed, 1 insertion(+) diff --git a/conf/configuration.go b/conf/configuration.go index bbe1e36c9..1cf97d0ca 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -520,6 +520,7 @@ func Load(noConfigDump bool) { if Server.LastFM.MinScrobbleDuration < 0 { log.Warn("MinScrobbleDuration must be non-negative, setting to default", "value", Server.LastFM.MinScrobbleDuration, "newValue", consts.DefaultMinScrobbleDuration) Server.LastFM.MinScrobbleDuration = consts.DefaultMinScrobbleDuration + } if Server.Scanner.Extractor != consts.DefaultScannerExtractor { log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor) From b39d25d2d692b551e17efec93f956825eeed31a1 Mon Sep 17 00:00:00 2001 From: Timo <101946596+timonet7@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:40:36 +0200 Subject: [PATCH 7/8] Fix condition to check song duration for scrobbling Changed from <= to < to ensure the scrobbling of a track that is exactly the duration of MinScrobbleDuration. Also ensures that MinScrobbleDuration = 0 will always lead to a scrobble, as a track with a duration of 0 (which I didn't think of) would otherwise be skipped. --- adapters/lastfm/agent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 8130cea3c..60a0fcccc 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -388,7 +388,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S return errors.Join(err, scrobbler.ErrNotAuthorized) } - if s.Duration <= float32(conf.Server.LastFM.MinScrobbleDuration.Seconds()) { + if s.Duration < float32(conf.Server.LastFM.MinScrobbleDuration.Seconds()) { log.Debug(ctx, "Skipping Last.fm scrobble for short song", "track", s.Title, "duration", s.Duration) return nil } From f8d980bca97a14e3d4604476ddc55dac347c3eb9 Mon Sep 17 00:00:00 2001 From: Timo <101946596+timonet7@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:49:27 +0200 Subject: [PATCH 8/8] Added closing braces / parentheses to MinScrobbleDuration configuration tests My tests were missing some finishing braces / parentheses, leading to an error. Fixed now. --- conf/configuration_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 1d3aee4ac..043c9e64f 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -424,6 +424,8 @@ var _ = Describe("Configuration", func() { viper.SetDefault("lastfm.minscrobbleduration", "45s") conf.Load(true) Expect(conf.Server.LastFM.MinScrobbleDuration).To(Equal(45 * time.Second)) + }) + }) Describe("Scanner.Extractor", func() { BeforeEach(func() {