mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-06 11:30:05 +00:00
fix(trakt): restore seek scrobble updates
This commit is contained in:
parent
cac2c891c0
commit
2f19d06fd3
8 changed files with 170 additions and 1 deletions
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue