scrobble: only dedupe when the track was already announced

The now-playing suppression keyed off the item id alone, so a track loaded while paused (which sets the tracked id without announcing) never sent its first now-playing when playback then started from the beginning. Gate the suppression on nowPlayingSent as well, and cover that case plus the transition at the combine boundary.
This commit is contained in:
austinried
2026-10-09 12:31:48 +00:00
parent cfb663c250
commit 20c6ba526f
3 changed files with 40 additions and 2 deletions
@@ -59,7 +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
val alreadyAnnounced = nowPlayingSent && item?.id == songId
songId = item?.id
lastItemId = null
playedMs = 0
@@ -69,7 +69,7 @@ class ScrobblePolicy(
if (item == null || !isPlaying) return null
nowPlayingSent = true
startedAt = now()
return if (sameSong) null else Scrobble.NowPlaying(item.id)
return if (alreadyAnnounced) null else Scrobble.NowPlaying(item.id)
}
lastPositionMs = positionMs
lastItemId = item?.id
@@ -170,6 +170,14 @@ class ScrobblePolicyTest {
)
}
@Test
fun announcesWhenATrackLoadedWhilePausedStartsFromTheBeginning() {
val item = track(id = "s1")
assertNull(policy.advance(item, 200_000, isPlaying = false, positionMs = 0))
assertNull(policy.advance(item, 200_000, isPlaying = false, positionMs = 2_500))
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, 200_000, isPlaying = true, positionMs = 0))
}
@Test
fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() {
val first = track(id = "s1")
@@ -190,4 +190,34 @@ class ScrobblerTest {
enabled.value = true
assertEquals(listOf(Call.NowPlaying("s1")), sink.calls)
}
@Test
fun aTransitionSendsNowPlayingOnceForTheNewTrack() =
runTest {
val sink = RecordingSink()
val scrobbler = Scrobbler(sink = sink, enabled = flowOf(true), scope = testScope(), policy = ScrobblePolicy(now = { 1_000L }))
val state = MutableStateFlow(PlaybackState())
val position = MutableStateFlow(0L)
scrobbler.attach(state, position)
val first = QueueItem("s1", "One", "Artist", "Album", null, durationMs = 30_000)
val second = QueueItem("s2", "Two", "Artist", "Album", null, durationMs = 30_000)
state.value = PlaybackState(item = first, isPlaying = true)
var tick = 500L
while (tick <= 15_000L) {
position.value = tick
tick += 500
}
position.value = 0L
state.value = PlaybackState(item = second, isPlaying = true)
assertEquals(
listOf(
Call.NowPlaying("s1"),
Call.Scrobble("s1", 1_000L),
Call.NowPlaying("s2"),
),
sink.calls,
)
}
}