fix(trakt): restore seek scrobble updates

This commit is contained in:
tapframe 2026-07-22 21:26:10 +05:30
parent cac2c891c0
commit 2f19d06fd3
8 changed files with 170 additions and 1 deletions

View file

@ -69,6 +69,7 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
initialLoadCompleted = false
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
pendingSeekScrobbleRestart = false
seekProgressSyncJob?.cancel()
seekProgressSyncJob = null
accumulatedSeekResetJob?.cancel()
@ -335,14 +336,23 @@ private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() {
if (playbackSnapshot.isEnded) {
flushWatchProgress(TrackingScrobbleAction.STOP)
previousIsPlaying = false
pendingSeekScrobbleRestart = false
return@LaunchedEffect
}
if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) {
pendingSeekScrobbleRestart = false
flushWatchProgress(TrackingScrobbleAction.PAUSE)
}
if (!previousIsPlaying && playbackSnapshot.isPlaying) {
if (playbackSnapshot.isPlaying && pendingSeekScrobbleRestart) {
pendingSeekScrobbleRestart = false
if (hasRequestedScrobbleStartForCurrentItem) {
emitTrackingSeekScrobbleStart()
} else {
emitTrackingScrobbleStart()
}
} else if (!previousIsPlaying && playbackSnapshot.isPlaying) {
emitTrackingScrobbleStart()
}

View file

@ -59,6 +59,7 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() {
(activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f)
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
pendingSeekScrobbleRestart = false
autoFetchedAddonSubtitlesForKey = null
trackPreferenceRestoreApplied = false
preferredAudioSelectionApplied = false
@ -70,6 +71,7 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() {
lastResetVideoIdentity = videoIdentity
hasRequestedScrobbleStartForCurrentItem = false
scrobbleStartRequestGeneration = 0L
pendingSeekScrobbleRestart = false
hasSentCompletionScrobbleForCurrentItem = false
currentTrackingMedia = null
}
@ -179,6 +181,7 @@ private fun PlayerScreenRuntime.emitTrackingScrobbleTerminal(
}
currentTrackingMedia = null
hasRequestedScrobbleStartForCurrentItem = false
pendingSeekScrobbleRestart = false
scrobbleStartRequestGeneration += 1L
}
@ -195,6 +198,28 @@ internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() {
}
}
internal fun shouldUpdateTrackingScrobbleAfterSeek(
hasActiveScrobble: Boolean,
progressPercent: Float,
): Boolean = hasActiveScrobble && progressPercent >= 1f && progressPercent < 80f
internal fun PlayerScreenRuntime.emitTrackingSeekScrobbleStart() {
val mediaSnapshot = currentTrackingMedia
val inputsSnapshot = snapshotTrackingScrobbleItemInputs()
scope.launch {
val media = mediaSnapshot ?: inputsSnapshot.buildMedia()
if (!media.hasResolvableIdentity) return@launch
TrackingScrobbleCoordinator.scrobbleSeek(
profileId = profileId,
action = TrackingScrobbleAction.START,
event = TrackingScrobbleEvent(
media = media,
progressPercent = currentPlaybackProgressPercent().toDouble(),
),
)
}
}
internal fun PlayerScreenRuntime.tryShowParentalGuide() {
if (!playerSettingsUiState.showParentalGuide) return
if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) {
@ -230,6 +255,7 @@ internal fun PlayerScreenRuntime.flushWatchProgress(
}
internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() {
val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying
seekProgressSyncJob?.cancel()
seekProgressSyncJob = scope.launch {
delay(PlayerSeekProgressSyncDebounceMs)
@ -238,6 +264,42 @@ internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() {
snapshot = playbackSnapshot,
)
val progressPercent = currentPlaybackProgressPercent()
if (
!shouldUpdateTrackingScrobbleAfterSeek(
hasActiveScrobble = hasRequestedScrobbleStartForCurrentItem,
progressPercent = progressPercent,
)
) {
return@launch
}
val media = currentTrackingMedia ?: currentTrackingMedia()
if (!media.hasResolvableIdentity) return@launch
val stopEvent = TrackingScrobbleEvent(
media = media,
progressPercent = progressPercent.toDouble(),
)
scope.launch {
TrackingScrobbleCoordinator.scrobbleSeek(
profileId = profileId,
action = TrackingScrobbleAction.STOP,
event = stopEvent,
)
if (!shouldRestartScrobbleAfterSeek || !shouldPlay || playbackSnapshot.isEnded) return@launch
if (playbackSnapshot.isPlaying) {
pendingSeekScrobbleRestart = false
TrackingScrobbleCoordinator.scrobbleSeek(
profileId = profileId,
action = TrackingScrobbleAction.START,
event = stopEvent.copy(
progressPercent = currentPlaybackProgressPercent().toDouble(),
),
)
} else {
pendingSeekScrobbleRestart = true
}
}
}
}

View file

@ -149,6 +149,7 @@ internal class PlayerScreenRuntime(
var previousIsPlaying by mutableStateOf(false)
var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false)
var scrobbleStartRequestGeneration by mutableStateOf(0L)
var pendingSeekScrobbleRestart by mutableStateOf(false)
var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false)
var currentTrackingMedia by mutableStateOf<TrackingMediaReference?>(null)

View file

@ -35,8 +35,43 @@ object TrackingScrobbleCoordinator {
}
return failures
}
suspend fun scrobbleSeek(
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
): List<TrackingScrobbleFailure> {
if (profileId != ProfileRepository.activeProfileId) return emptyList()
TrackingProviderRegistry.ensureLoaded()
val failures = dispatchTrackingSeekScrobble(
scrobblers = TrackingProviderRegistry.connectedScrobblers(),
profileId = profileId,
action = action,
event = event,
)
failures.forEach { failure ->
log.w(failure.cause) {
"${failure.providerId.storageId} seek scrobble ${action.wireValue} failed"
}
}
return failures
}
}
internal suspend fun dispatchTrackingSeekScrobble(
scrobblers: Collection<TrackingScrobbler>,
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
): List<TrackingScrobbleFailure> = dispatchTrackingScrobble(
scrobblers = scrobblers.filter { scrobbler ->
scrobbler.seekScrobblePolicy == TrackingSeekScrobblePolicy.STOP_AND_RESTART
},
profileId = profileId,
action = action,
event = event,
)
internal suspend fun dispatchTrackingScrobble(
scrobblers: Collection<TrackingScrobbler>,
profileId: Int,

View file

@ -19,6 +19,11 @@ enum class TrackingScrobbleAction(val wireValue: String) {
STOP("stop"),
}
enum class TrackingSeekScrobblePolicy {
NONE,
STOP_AND_RESTART,
}
data class TrackingHistoryItem(
val media: TrackingMediaReference,
val watchedAtEpochMs: Long? = null,
@ -78,6 +83,8 @@ interface TrackingHistoryWriter {
interface TrackingScrobbler {
val providerId: TrackingProviderId
val seekScrobblePolicy: TrackingSeekScrobblePolicy
get() = TrackingSeekScrobblePolicy.NONE
suspend fun scrobble(
profileId: Int,

View file

@ -11,6 +11,7 @@ import com.nuvio.app.features.tracking.TrackingProviderRegistry
import com.nuvio.app.features.tracking.TrackingScrobbleAction
import com.nuvio.app.features.tracking.TrackingScrobbleEvent
import com.nuvio.app.features.tracking.TrackingScrobbler
import com.nuvio.app.features.tracking.TrackingSeekScrobblePolicy
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
@ -75,6 +76,8 @@ internal fun TrackingMediaReference.toTraktEpisodeMappingInput(): TraktEpisodeMa
internal object TraktScrobbleRepository : TrackingScrobbler {
override val providerId: TrackingProviderId = TrackingProviderId.TRAKT
override val seekScrobblePolicy: TrackingSeekScrobblePolicy =
TrackingSeekScrobblePolicy.STOP_AND_RESTART
private data class ScrobbleStamp(
val profileId: Int,

View file

@ -5,7 +5,9 @@ import androidx.compose.ui.Modifier
import com.nuvio.app.features.streams.StreamsUiState
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PlayerScreenRuntimeStateTest {
@ -21,6 +23,28 @@ class PlayerScreenRuntimeStateTest {
assertEquals("addon-id", selectedFilter.value)
}
@Test
fun seekScrobbleUpdate_requiresActiveIncompletePlayback() {
assertTrue(
shouldUpdateTrackingScrobbleAfterSeek(
hasActiveScrobble = true,
progressPercent = 50f,
),
)
assertFalse(
shouldUpdateTrackingScrobbleAfterSeek(
hasActiveScrobble = false,
progressPercent = 50f,
),
)
assertFalse(
shouldUpdateTrackingScrobbleAfterSeek(
hasActiveScrobble = true,
progressPercent = 80f,
),
)
}
private fun testPlayerScreenArgs() = PlayerScreenArgs(
profileId = 1,
title = "Title",

View file

@ -29,8 +29,35 @@ class TrackingScrobbleCoordinatorTest {
assertEquals(listOf(TrackingProviderId.SIMKL), failures.map(TrackingScrobbleFailure::providerId))
}
@Test
fun `seek fanout targets only providers that restart scrobbles`() = runBlocking {
val trakt = FakeScrobbler(
providerId = TrackingProviderId.TRAKT,
seekScrobblePolicy = TrackingSeekScrobblePolicy.STOP_AND_RESTART,
)
val simkl = FakeScrobbler(TrackingProviderId.SIMKL)
val event = TrackingScrobbleEvent(
media = TrackingMediaReference(
kind = TrackingMediaKind.MOVIE,
ids = TrackingExternalIds(imdb = "tt0111161"),
),
progressPercent = 55.0,
)
dispatchTrackingSeekScrobble(
scrobblers = listOf(trakt, simkl),
profileId = 2,
action = TrackingScrobbleAction.STOP,
event = event,
)
assertEquals(1, trakt.callCount)
assertEquals(0, simkl.callCount)
}
private class FakeScrobbler(
override val providerId: TrackingProviderId,
override val seekScrobblePolicy: TrackingSeekScrobblePolicy = TrackingSeekScrobblePolicy.NONE,
private val failure: Throwable? = null,
) : TrackingScrobbler {
var callCount: Int = 0