From cfb663c250546e63a13573e036dce18e53f171d2 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:30:57 +0000 Subject: [PATCH] scrobble: send now playing once per track change A track transition resets the player position, and the scrobble policy's restart heuristic (same song, position drops to ~0) then re-announced the track we just left and, when the new track's position jittered back to zero, announced it twice. Suppress the now-playing when the restart is the same song; the submission logic still resets so a genuine replay is scrobbled again. --- .../com/subtracks/playback/Scrobbler.kt | 3 +- .../subtracks/playback/ScrobblePolicyTest.kt | 35 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/playback/Scrobbler.kt b/app/src/main/kotlin/com/subtracks/playback/Scrobbler.kt index f8dbd97d..f033d12f 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Scrobbler.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Scrobbler.kt @@ -59,6 +59,7 @@ class ScrobblePolicy( item?.id != songId || (item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS) if (restarted) { + val sameSong = item != null && item.id == songId songId = item?.id lastItemId = null playedMs = 0 @@ -68,7 +69,7 @@ class ScrobblePolicy( if (item == null || !isPlaying) return null nowPlayingSent = true startedAt = now() - return Scrobble.NowPlaying(item.id) + return if (sameSong) null else Scrobble.NowPlaying(item.id) } lastPositionMs = positionMs lastItemId = item?.id diff --git a/app/src/test/kotlin/com/subtracks/playback/ScrobblePolicyTest.kt b/app/src/test/kotlin/com/subtracks/playback/ScrobblePolicyTest.kt index d1a22faf..d451514a 100644 --- a/app/src/test/kotlin/com/subtracks/playback/ScrobblePolicyTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/ScrobblePolicyTest.kt @@ -130,15 +130,46 @@ class ScrobblePolicyTest { } @Test - fun restartingTheSameTrackScrobblesAgain() { + fun restartingTheSameTrackResubmitsWithoutReannouncing() { val item = track(durationMs = 200_000) play(item, toPositionMs = 100_000) clock = 120_000L val repeat = play(item, toPositionMs = 100_000) - assertEquals(Scrobble.NowPlaying("s1"), repeat.first()) + assertEquals(emptyList(), repeat.filterIsInstance()) assertEquals(Scrobble.Submission("s1", 120_000L), repeat.last()) } + @Test + fun doesNotReannounceTheTrackItJustLeftWhenItsPositionResets() { + val events = mutableListOf() + events += play(track(id = "s1"), toPositionMs = 100_000) + events += + listOfNotNull( + policy.advance(track(id = "s1"), 200_000, isPlaying = true, positionMs = 0), + policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0), + ) + assertEquals( + listOf(Scrobble.NowPlaying("s1"), Scrobble.Submission("s1", 1_000L), Scrobble.NowPlaying("s2")), + events, + ) + } + + @Test + fun doesNotReannounceWhenTheNewTracksPositionJittersBackToZero() { + val events = mutableListOf() + events += play(track(id = "s1"), toPositionMs = 100_000) + events += + listOfNotNull( + policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0), + policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 500), + policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0), + ) + assertEquals( + listOf(Scrobble.NowPlaying("s1"), Scrobble.Submission("s1", 1_000L), Scrobble.NowPlaying("s2")), + events, + ) + } + @Test fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() { val first = track(id = "s1")