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
@@ -1213,7 +1213,6 @@ class PlaybackController(
player.durationMs.takeIf { it > 0 }
?: player.currentItem?.durationMs?.takeIf { it > 0 }
?: 0L
_positionMs.value = player.currentPositionMs
_state.value =
PlaybackState(
item = player.currentItem,
@@ -1228,6 +1227,7 @@ class PlaybackController(
repeat = repeatMode,
layout = layoutVersion,
)
_positionMs.value = player.currentPositionMs
if (player.isPlaying) startPositionTicker() else stopPositionTicker()
}
@@ -1239,7 +1239,9 @@ class PlaybackController(
delay(POSITION_TICK_MS)
val player = player ?: break
val positionMs = player.currentPositionMs
_positionMs.value = positionMs
if (player.currentItem?.id == _state.value.item?.id) {
_positionMs.value = positionMs
}
if (positionMs - lastSavedPositionMs >= POSITION_SAVE_INTERVAL_MS || positionMs < lastSavedPositionMs) {
lastSavedPositionMs = positionMs
queueRepository.setPosition(positionMs)
@@ -5,11 +5,12 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
sealed interface Scrobble {
val songId: String
@@ -28,6 +29,7 @@ class ScrobblePolicy(
private val now: () -> Long = System::currentTimeMillis,
) {
private var songId: String? = null
private var lastItemId: String? = null
private var durationMs = 0L
private var startedAt = 0L
private var playedMs = 0L
@@ -37,6 +39,7 @@ class ScrobblePolicy(
fun reset() {
songId = null
lastItemId = null
playedMs = 0
lastPositionMs = 0
nowPlayingSent = false
@@ -45,13 +48,18 @@ class ScrobblePolicy(
fun advance(
item: QueueItem?,
durationMs: Long,
isPlaying: Boolean,
positionMs: Long,
): Scrobble? {
if (durationMs > 0) this.durationMs = durationMs
val previousPosition = lastPositionMs
if (item?.id != songId || positionMs < previousPosition) {
val restarted =
item?.id != songId ||
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
if (restarted) {
songId = item?.id
durationMs = item?.durationMs ?: 0L
lastItemId = null
playedMs = 0
lastPositionMs = positionMs
nowPlayingSent = false
@@ -62,6 +70,7 @@ class ScrobblePolicy(
return Scrobble.NowPlaying(item.id)
}
lastPositionMs = positionMs
lastItemId = item?.id
val id = songId ?: return null
if (!isPlaying) return null
if (!nowPlayingSent) {
@@ -85,6 +94,7 @@ class ScrobblePolicy(
const val MIN_DURATION_MS = 30_000L
const val MAX_THRESHOLD_MS = 4 * 60_000L
const val MAX_STEP_MS = 2_000L
const val RESTART_POSITION_MS = 3_000L
}
}
@@ -94,25 +104,26 @@ class Scrobbler(
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
private val policy: ScrobblePolicy = ScrobblePolicy(),
) {
private val submitLock = Mutex()
fun attach(
state: StateFlow<PlaybackState>,
positionMs: StateFlow<Long>,
) {
scope.launch {
combine(state, positionMs, enabled) { current, position, on -> Triple(current, position, on) }
combine(state, positionMs, enabled, ::Triple)
.collect { (current, position, on) ->
if (!on) {
policy.reset()
return@collect
}
val event = policy.advance(current.item, current.isPlaying, position) ?: return@collect
submit(event)
val durationMs = current.item?.durationMs?.takeIf { it > 0 } ?: current.durationMs
val event = policy.advance(current.item, durationMs, current.isPlaying, position) ?: return@collect
scope.launch { submitLock.withLock { submit(event) } }
}
}
}
fun close() = scope.cancel()
private suspend fun submit(event: Scrobble) {
try {
when (event) {