fix scrobble double-count and missed plays found in review

Only treat a rewind as a new listen when it lands in the first few
seconds, so a small seek-back no longer re-arms and duplicates a
scrobble. Fall back to the player's resolved duration when the queue
item has none, and stop the position flow publishing a value for a
track the state has not caught up to. Submit off the collector so an
offline write cannot stall counting.
This commit is contained in:
austinried
2026-10-04 00:30:04 +00:00
parent 0379102e24
commit 153122a552
5 changed files with 99 additions and 20 deletions
@@ -2,6 +2,7 @@ package com.subtracks.playback
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ScrobblePolicyTest {
@@ -16,13 +17,14 @@ class ScrobblePolicyTest {
private fun play(
item: QueueItem?,
toPositionMs: Long,
durationMs: Long = item?.durationMs ?: 0L,
stepMs: Long = 500,
playing: Boolean = true,
): List<Scrobble> {
val events = mutableListOf<Scrobble>()
var position = 0L
while (position <= toPositionMs) {
policy.advance(item, playing, position)?.let(events::add)
policy.advance(item, durationMs, playing, position)?.let(events::add)
position += stepMs
}
return events
@@ -37,9 +39,9 @@ class ScrobblePolicyTest {
@Test
fun waitsForPlaybackBeforeNowPlaying() {
val item = track()
assertNull(policy.advance(item, isPlaying = false, positionMs = 0))
assertNull(policy.advance(item, 200_000, isPlaying = false, positionMs = 0))
clock = 5_000L
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, isPlaying = true, positionMs = 0))
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, 200_000, isPlaying = true, positionMs = 0))
}
@Test
@@ -62,6 +64,18 @@ class ScrobblePolicyTest {
assertEquals(Scrobble.Submission("s1", time = 1_000L), events.last())
}
@Test
fun usesTheKnownDurationWhenTheQueueItemHasNone() {
val events = play(track(durationMs = 0), toPositionMs = 90_000, durationMs = 180_000)
assertEquals(Scrobble.Submission("s1", time = 1_000L), events.last())
}
@Test
fun anUnknownDurationSubmitsAtFourMinutes() {
val events = play(track(durationMs = 0), toPositionMs = 4 * 60_000, durationMs = 0)
assertEquals(Scrobble.Submission("s1", time = 1_000L), events.last())
}
@Test
fun doesNotSubmitShortTracks() {
val events = play(track(durationMs = 20_000), toPositionMs = 20_000)
@@ -77,17 +91,32 @@ class ScrobblePolicyTest {
@Test
fun seekingAheadDoesNotCountAsPlayback() {
val item = track(durationMs = 200_000)
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, isPlaying = true, positionMs = 0))
assertNull(policy.advance(item, isPlaying = true, positionMs = 180_000))
assertNull(policy.advance(item, isPlaying = true, positionMs = 180_500))
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, 200_000, isPlaying = true, positionMs = 0))
assertNull(policy.advance(item, 200_000, isPlaying = true, positionMs = 180_000))
assertNull(policy.advance(item, 200_000, isPlaying = true, positionMs = 180_500))
}
@Test
fun seekingBackAfterASubmissionDoesNotSubmitAgain() {
val item = track(durationMs = 600_000)
assertTrue(play(item, toPositionMs = 240_000).any { it is Scrobble.Submission })
assertNull(policy.advance(item, 600_000, isPlaying = true, positionMs = 235_000))
var position = 235_500L
val after = mutableListOf<Scrobble>()
while (position <= 300_000) {
policy.advance(item, 600_000, isPlaying = true, positionMs = position)?.let(after::add)
position += 500
}
assertEquals(emptyList<Scrobble>(), after)
}
@Test
fun pausedTimeIsNotCounted() {
val item = track(durationMs = 200_000)
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, isPlaying = true, positionMs = 0))
assertNull(policy.advance(item, isPlaying = false, positionMs = 120_000))
assertNull(policy.advance(item, isPlaying = true, positionMs = 120_500))
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, 200_000, isPlaying = true, positionMs = 0))
assertNull(policy.advance(item, 200_000, isPlaying = false, positionMs = 120_000))
assertNull(policy.advance(item, 200_000, isPlaying = true, positionMs = 120_500))
}
@Test
@@ -109,4 +138,15 @@ class ScrobblePolicyTest {
assertEquals(Scrobble.NowPlaying("s1"), repeat.first())
assertEquals(Scrobble.Submission("s1", 120_000L), repeat.last())
}
@Test
fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() {
val first = track(id = "s1")
val second = track(id = "s2")
policy.advance(first, 200_000, isPlaying = true, positionMs = 0)
policy.advance(first, 200_000, isPlaying = true, positionMs = 100_000)
assertEquals(Scrobble.NowPlaying("s2"), policy.advance(second, 200_000, isPlaying = true, positionMs = 199_000))
assertNull(policy.advance(second, 200_000, isPlaying = true, positionMs = 0))
}
}
@@ -80,6 +80,32 @@ class ScrobblerTest {
assertEquals(2, sink.calls.size)
}
@Test
fun fallsBackToThePlayerDurationWhenTheQueueItemHasNone() =
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 item = QueueItem("s1", "Title", "Artist", "Album", null, durationMs = null)
state.value = PlaybackState(item = item, isPlaying = true, durationMs = 30_000)
var tick = 500L
while (tick <= 15_000L) {
position.value = tick
tick += 500
}
assertEquals(
listOf(
Call.NowPlaying("s1"),
Call.Scrobble("s1", 1_000L),
),
sink.calls,
)
}
@Test
fun disabledSendsNothing() =
runTest {