feat(tracking): fan out real player scrobbles

This commit is contained in:
tapframe 2026-07-22 02:59:16 +05:30
parent 0f12e61430
commit 2dffff3520
12 changed files with 338 additions and 64 deletions

View file

@ -220,9 +220,11 @@ import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.tracking.TrackingScrobbleAction
import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator
import com.nuvio.app.features.tracking.TrackingScrobbleEvent
import com.nuvio.app.features.tracking.buildTrackingMediaReference
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.updater.AppUpdaterHost
import com.nuvio.app.features.updater.AppUpdaterPlatform
import com.nuvio.app.features.updater.rememberAppUpdaterController
@ -1233,8 +1235,8 @@ private fun MainAppContent(
null
}
val playerLaunch = lastExternalPlayerLaunch
if (TraktAuthRepository.isAuthenticated.value && progressPercent != null && playerLaunch != null) {
val scrobbleItem = TraktScrobbleRepository.buildItem(
if (progressPercent != null && playerLaunch != null) {
val trackingMedia = buildTrackingMediaReference(
contentType = playerLaunch.parentMetaType,
parentMetaId = playerLaunch.parentMetaId,
videoId = playerLaunch.videoId,
@ -1243,12 +1245,15 @@ private fun MainAppContent(
episodeNumber = playerLaunch.episodeNumber,
episodeTitle = playerLaunch.episodeTitle,
)
if (scrobbleItem != null) {
if (trackingMedia.hasResolvableIdentity) {
runCatching {
TraktScrobbleRepository.scrobbleStop(
TrackingScrobbleCoordinator.scrobble(
profileId = playerLaunch.profileId,
item = scrobbleItem,
progressPercent = progressPercent,
action = TrackingScrobbleAction.STOP,
event = TrackingScrobbleEvent(
media = trackingMedia,
progressPercent = progressPercent.toDouble(),
),
)
}
}

View file

@ -6,9 +6,11 @@ import com.nuvio.app.features.simkl.SimklLibraryRepository
import com.nuvio.app.features.simkl.SimklProgressRepository
import com.nuvio.app.features.simkl.SimklSyncRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktScrobbleRepository
fun ensureTrackingProvidersRegistered() {
TraktAuthRepository.descriptor
TraktScrobbleRepository.ensureRegistered()
SimklAuthRepository.descriptor
SimklSyncRepository.state
SimklLibraryRepository.uiState

View file

@ -15,6 +15,7 @@ import com.nuvio.app.features.streams.BingeGroupCacheRepository
import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.hasLikelyExpiringPlaybackCredentials
import com.nuvio.app.features.tracking.TrackingScrobbleAction
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
@ -68,7 +69,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
initialLoadCompleted = false
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
seekProgressSyncJob?.cancel()
seekProgressSyncJob = null
accumulatedSeekResetJob?.cancel()
@ -333,22 +333,17 @@ private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() {
playbackSnapshot.durationMs,
) {
if (playbackSnapshot.isEnded) {
flushWatchProgress()
flushWatchProgress(TrackingScrobbleAction.STOP)
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
return@LaunchedEffect
}
if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) {
pendingScrobbleStartAfterSeek = false
flushWatchProgress()
flushWatchProgress(TrackingScrobbleAction.PAUSE)
}
if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) {
pendingScrobbleStartAfterSeek = false
emitTraktScrobbleStart()
} else if (!previousIsPlaying && playbackSnapshot.isPlaying) {
emitTraktScrobbleStart()
if (!previousIsPlaying && playbackSnapshot.isPlaying) {
emitTrackingScrobbleStart()
}
if (!playbackSnapshot.isLoading) {

View file

@ -1,7 +1,11 @@
package com.nuvio.app.features.player
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.tracking.TrackingMediaReference
import com.nuvio.app.features.tracking.TrackingScrobbleAction
import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator
import com.nuvio.app.features.tracking.TrackingScrobbleEvent
import com.nuvio.app.features.tracking.buildTrackingMediaReference
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -55,7 +59,6 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() {
(activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f)
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
autoFetchedAddonSubtitlesForKey = null
trackPreferenceRestoreApplied = false
preferredAudioSelectionApplied = false
@ -67,9 +70,8 @@ internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() {
lastResetVideoIdentity = videoIdentity
hasRequestedScrobbleStartForCurrentItem = false
scrobbleStartRequestGeneration = 0L
pendingScrobbleStartAfterSeek = false
hasSentCompletionScrobbleForCurrentItem = false
currentTraktScrobbleItem = null
currentTrackingMedia = null
}
}
@ -81,7 +83,7 @@ internal fun PlayerScreenRuntime.currentPlaybackProgressPercent(
.coerceIn(0f, 100f)
}
internal data class TraktScrobbleItemInputs(
internal data class TrackingScrobbleItemInputs(
val contentType: String,
val parentMetaId: String,
val videoId: String?,
@ -91,7 +93,7 @@ internal data class TraktScrobbleItemInputs(
val episodeTitle: String?,
)
internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobbleItemInputs(
internal fun PlayerScreenRuntime.snapshotTrackingScrobbleItemInputs() = TrackingScrobbleItemInputs(
contentType = contentType ?: parentMetaType,
parentMetaId = parentMetaId,
videoId = activeVideoId,
@ -101,8 +103,8 @@ internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobb
episodeTitle = activeEpisodeTitle,
)
private suspend fun TraktScrobbleItemInputs.buildItem() =
TraktScrobbleRepository.buildItem(
private fun TrackingScrobbleItemInputs.buildMedia(): TrackingMediaReference =
buildTrackingMediaReference(
contentType = contentType,
parentMetaId = parentMetaId,
videoId = videoId,
@ -112,49 +114,70 @@ private suspend fun TraktScrobbleItemInputs.buildItem() =
episodeTitle = episodeTitle,
)
internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() =
snapshotTraktScrobbleItemInputs().buildItem()
internal fun PlayerScreenRuntime.currentTrackingMedia(): TrackingMediaReference =
snapshotTrackingScrobbleItemInputs().buildMedia()
internal fun PlayerScreenRuntime.emitTraktScrobbleStart() {
internal fun PlayerScreenRuntime.emitTrackingScrobbleStart() {
if (hasRequestedScrobbleStartForCurrentItem) return
hasRequestedScrobbleStartForCurrentItem = true
val requestGeneration = scrobbleStartRequestGeneration + 1L
scrobbleStartRequestGeneration = requestGeneration
scope.launch {
val item = currentTraktScrobbleItem()
if (item == null) {
val media = currentTrackingMedia()
if (!media.hasResolvableIdentity) {
hasRequestedScrobbleStartForCurrentItem = false
return@launch
}
if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) {
return@launch
}
currentTraktScrobbleItem = item
TraktScrobbleRepository.scrobbleStart(
currentTrackingMedia = media
TrackingScrobbleCoordinator.scrobble(
profileId = profileId,
item = item,
progressPercent = currentPlaybackProgressPercent(),
action = TrackingScrobbleAction.START,
event = TrackingScrobbleEvent(
media = media,
progressPercent = currentPlaybackProgressPercent().toDouble(),
),
)
}
}
internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? = null) {
internal fun PlayerScreenRuntime.emitTrackingScrobblePause(progressPercent: Float? = null) {
emitTrackingScrobbleTerminal(
action = TrackingScrobbleAction.PAUSE,
progressPercent = progressPercent,
)
}
internal fun PlayerScreenRuntime.emitTrackingScrobbleStop(progressPercent: Float? = null) {
emitTrackingScrobbleTerminal(
action = TrackingScrobbleAction.STOP,
progressPercent = progressPercent,
)
}
private fun PlayerScreenRuntime.emitTrackingScrobbleTerminal(
action: TrackingScrobbleAction,
progressPercent: Float?,
) {
val provided = progressPercent
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
val percent = provided ?: currentPlaybackProgressPercent()
val itemSnapshot = currentTraktScrobbleItem
val inputsSnapshot = snapshotTraktScrobbleItemInputs()
val mediaSnapshot = currentTrackingMedia
val inputsSnapshot = snapshotTrackingScrobbleItemInputs()
scope.launch(NonCancellable) {
val item = itemSnapshot ?: inputsSnapshot.buildItem() ?: return@launch
TraktScrobbleRepository.scrobbleStop(
val media = mediaSnapshot ?: inputsSnapshot.buildMedia()
if (!media.hasResolvableIdentity) return@launch
TrackingScrobbleCoordinator.scrobble(
profileId = profileId,
item = item,
progressPercent = percent,
action = action,
event = TrackingScrobbleEvent(media = media, progressPercent = percent.toDouble()),
)
}
currentTraktScrobbleItem = null
currentTrackingMedia = null
hasRequestedScrobbleStartForCurrentItem = false
scrobbleStartRequestGeneration += 1L
}
@ -162,13 +185,13 @@ internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? =
internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() {
val progressPercent = currentPlaybackProgressPercent()
if (progressPercent >= 1f && progressPercent < 80f) {
emitTraktScrobbleStop(progressPercent)
emitTrackingScrobbleStop(progressPercent)
return
}
if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) {
hasSentCompletionScrobbleForCurrentItem = true
emitTraktScrobbleStop(progressPercent)
emitTrackingScrobbleStop(progressPercent)
}
}
@ -192,8 +215,14 @@ internal suspend fun PlayerScreenRuntime.resolveParentalGuideImdbId(): String? {
)
}
internal fun PlayerScreenRuntime.flushWatchProgress() {
emitStopScrobbleForCurrentProgress()
internal fun PlayerScreenRuntime.flushWatchProgress(
scrobbleAction: TrackingScrobbleAction = TrackingScrobbleAction.STOP,
) {
when (scrobbleAction) {
TrackingScrobbleAction.PAUSE -> emitTrackingScrobblePause()
TrackingScrobbleAction.STOP -> emitStopScrobbleForCurrentProgress()
TrackingScrobbleAction.START -> Unit
}
WatchProgressRepository.flushPlaybackProgress(
session = playbackSession,
snapshot = playbackSnapshot,
@ -201,7 +230,6 @@ internal fun PlayerScreenRuntime.flushWatchProgress() {
}
internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() {
val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying
seekProgressSyncJob?.cancel()
seekProgressSyncJob = scope.launch {
delay(PlayerSeekProgressSyncDebounceMs)
@ -210,17 +238,6 @@ internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() {
snapshot = playbackSnapshot,
)
val progressPercent = currentPlaybackProgressPercent()
if (progressPercent >= 1f && progressPercent < 80f) {
emitTraktScrobbleStop(progressPercent)
val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay
if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) {
pendingScrobbleStartAfterSeek = false
emitTraktScrobbleStart()
} else if (shouldRestartScrobbleNow) {
pendingScrobbleStartAfterSeek = true
}
}
}
}

View file

@ -16,7 +16,7 @@ import com.nuvio.app.features.p2p.P2pStreamingState
import com.nuvio.app.features.player.skip.NextEpisodeInfo
import com.nuvio.app.features.player.skip.SkipInterval
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.trakt.TraktScrobbleItem
import com.nuvio.app.features.tracking.TrackingMediaReference
import com.nuvio.app.features.watched.WatchedUiState
import com.nuvio.app.features.watchprogress.WatchProgressUiState
import kotlinx.coroutines.CoroutineScope
@ -149,9 +149,8 @@ internal class PlayerScreenRuntime(
var previousIsPlaying by mutableStateOf(false)
var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false)
var scrobbleStartRequestGeneration by mutableStateOf(0L)
var pendingScrobbleStartAfterSeek by mutableStateOf(false)
var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false)
var currentTraktScrobbleItem by mutableStateOf<TraktScrobbleItem?>(null)
var currentTrackingMedia by mutableStateOf<TrackingMediaReference?>(null)
var showSourcesPanel by mutableStateOf(false)
var showEpisodesPanel by mutableStateOf(false)

View file

@ -169,7 +169,13 @@ object SimklMutationRepository : TrackingListWriter, TrackingHistoryWriter, Trac
event: TrackingScrobbleEvent,
) {
if (!isActiveProfile(profileId)) return
service.scrobble(action, event)
SimklSyncRepository.ensureLoaded()
service.scrobble(
action = action,
event = event.copy(
media = SimklSyncRepository.state.value.snapshot.enrichMediaReference(event.media),
),
)
}
private fun isActiveProfile(profileId: Int): Boolean = ProfileRepository.activeProfileId == profileId

View file

@ -141,6 +141,24 @@ internal fun SimklSyncSnapshot.mediaReference(
)
}
internal fun SimklSyncSnapshot.enrichMediaReference(reference: TrackingMediaReference): TrackingMediaReference {
val entry = entries.firstOrNull { candidate ->
candidate.media?.toTrackingExternalIds()?.sharesIdentityWith(reference.ids) == true
} ?: return reference
val media = entry.media ?: return reference
val kind = when (entry.mediaType) {
SimklMediaType.MOVIES -> TrackingMediaKind.MOVIE
SimklMediaType.SHOWS -> TrackingMediaKind.SHOW
SimklMediaType.ANIME -> TrackingMediaKind.ANIME
}
return reference.copy(
kind = kind,
title = media.title?.takeIf(String::isNotBlank) ?: reference.title,
year = media.year ?: reference.year,
ids = media.toTrackingExternalIds().mergeMissing(reference.ids),
)
}
internal fun SimklMedia.toTrackingExternalIds(): TrackingExternalIds = TrackingExternalIds(
simkl = ids.simklIdValue()?.toLongOrNull(),
imdb = ids.idValue("imdb"),
@ -290,6 +308,16 @@ private fun SimklLibraryEntry.matchesContentId(contentId: String): Boolean {
(parsed.kitsu != null && parsed.kitsu == candidateIds.kitsu)
}
private fun TrackingExternalIds.sharesIdentityWith(other: TrackingExternalIds): Boolean =
(simkl != null && simkl == other.simkl) ||
(!imdb.isNullOrBlank() && imdb.equals(other.imdb, ignoreCase = true)) ||
(tmdb != null && tmdb == other.tmdb) ||
(!tvdb.isNullOrBlank() && tvdb.equals(other.tvdb, ignoreCase = true)) ||
(mal != null && mal == other.mal) ||
(anidb != null && anidb == other.anidb) ||
(anilist != null && anilist == other.anilist) ||
(kitsu != null && kitsu == other.kitsu)
private fun Int.isLeapYear(): Boolean = (this % 4 == 0 && this % 100 != 0) || this % 400 == 0
private fun daysInMonths(year: Int): IntArray = if (year.isLeapYear()) {

View file

@ -122,4 +122,31 @@ fun extractTrackingYear(value: String?): Int? =
?.let { YEAR_PATTERN.find(it)?.value }
?.toIntOrNull()
fun buildTrackingMediaReference(
contentType: String,
parentMetaId: String,
videoId: String? = null,
title: String? = null,
releaseInfo: String? = null,
seasonNumber: Int? = null,
episodeNumber: Int? = null,
episodeTitle: String? = null,
): TrackingMediaReference {
val ids = parseTrackingExternalIds(parentMetaId)
.mergeMissing(parseTrackingExternalIds(videoId))
return TrackingMediaReference(
kind = trackingMediaKind(contentType, ids),
title = title?.trim()?.takeIf(String::isNotBlank),
year = extractTrackingYear(releaseInfo),
ids = ids,
episode = episodeNumber?.let { number ->
TrackingEpisode(
season = seasonNumber,
number = number,
title = episodeTitle,
)
},
)
}
private val YEAR_PATTERN = Regex("(19|20)\\d{2}")

View file

@ -0,0 +1,58 @@
package com.nuvio.app.features.tracking
import co.touchlab.kermit.Logger
import com.nuvio.app.features.profiles.ProfileRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
data class TrackingScrobbleFailure(
val providerId: TrackingProviderId,
val cause: Throwable,
)
object TrackingScrobbleCoordinator {
private val log = Logger.withTag("TrackingScrobble")
suspend fun scrobble(
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
): List<TrackingScrobbleFailure> {
if (profileId != ProfileRepository.activeProfileId) return emptyList()
TrackingProviderRegistry.ensureLoaded()
val failures = dispatchTrackingScrobble(
scrobblers = TrackingProviderRegistry.connectedScrobblers(),
profileId = profileId,
action = action,
event = event,
)
failures.forEach { failure ->
log.w(failure.cause) {
"${failure.providerId.storageId} scrobble ${action.wireValue} failed"
}
}
return failures
}
}
internal suspend fun dispatchTrackingScrobble(
scrobblers: Collection<TrackingScrobbler>,
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
): List<TrackingScrobbleFailure> = supervisorScope {
scrobblers.map { scrobbler ->
async {
try {
scrobbler.scrobble(profileId = profileId, action = action, event = event)
null
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
TrackingScrobbleFailure(providerId = scrobbler.providerId, cause = error)
}
}
}.awaitAll().filterNotNull()
}

View file

@ -4,6 +4,13 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppVersionConfig
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tracking.TrackingMediaKind
import com.nuvio.app.features.tracking.TrackingMediaReference
import com.nuvio.app.features.tracking.TrackingProviderId
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
@ -39,7 +46,9 @@ internal sealed interface TraktScrobbleItem {
}
}
internal object TraktScrobbleRepository {
internal object TraktScrobbleRepository : TrackingScrobbler {
override val providerId: TrackingProviderId = TrackingProviderId.TRAKT
private data class ScrobbleStamp(
val profileId: Int,
val action: String,
@ -62,6 +71,26 @@ internal object TraktScrobbleRepository {
private val retryDelayMs = 1_500L
private val serverOverloadedRetryDelayMs = 5_000L
init {
TrackingProviderRegistry.registerScrobbler(this)
}
fun ensureRegistered() = Unit
override suspend fun scrobble(
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
) {
val item = buildItem(event.media) ?: return
sendScrobble(
profileId = profileId,
action = action.wireValue,
item = item,
progressPercent = event.progressPercent.toFloat(),
)
}
suspend fun scrobbleStart(profileId: Int, item: TraktScrobbleItem, progressPercent: Float) {
sendScrobble(profileId = profileId, action = "start", item = item, progressPercent = progressPercent)
}
@ -126,6 +155,45 @@ internal object TraktScrobbleRepository {
}
}
private suspend fun buildItem(media: TrackingMediaReference): TraktScrobbleItem? {
val ids = TraktExternalIds(
trakt = media.ids.trakt?.toTraktIntOrNull(),
imdb = media.ids.imdb?.takeIf(String::isNotBlank),
tmdb = media.ids.tmdb?.toTraktIntOrNull(),
)
if (!ids.hasAnyId()) return null
if (media.kind == TrackingMediaKind.MOVIE) {
return TraktScrobbleItem.Movie(
title = media.title,
year = media.year,
ids = ids,
)
}
val episode = media.episode ?: return null
val season = episode.season ?: return null
val contentId = ids.imdb
?: ids.tmdb?.let { value -> "tmdb:$value" }
?: ids.trakt?.let { value -> "trakt:$value" }
?: return null
val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping(
contentId = contentId,
contentType = "series",
videoId = null,
season = season,
episode = episode.number,
episodeTitle = episode.title,
)
return TraktScrobbleItem.Episode(
showTitle = media.title,
showYear = media.year,
showIds = ids,
season = mappedEpisode?.season ?: season,
number = mappedEpisode?.episode ?: episode.number,
episodeTitle = episode.title,
)
}
private suspend fun sendScrobble(
profileId: Int,
action: String,
@ -325,6 +393,8 @@ internal object TraktScrobbleRepository {
}
}
private fun Long.toTraktIntOrNull(): Int? = takeIf { value -> value in 1L..Int.MAX_VALUE.toLong() }?.toInt()
@Serializable
private data class TraktScrobbleRequest(
@SerialName("movie") val movie: TraktMovieBody? = null,

View file

@ -32,4 +32,24 @@ class TrackingMediaTest {
assertEquals(TrackingMediaKind.MOVIE, trackingMediaKind("film"))
assertFalse(TrackingExternalIds().hasAny)
}
@Test
fun `playback media builder falls back to video id and keeps episode coordinates`() {
val media = buildTrackingMediaReference(
contentType = "series",
parentMetaId = "addon_specific_identifier",
videoId = "tt4574334:2:7",
title = "Stranger Things",
releaseInfo = "2016",
seasonNumber = 2,
episodeNumber = 7,
episodeTitle = "The Lost Sister",
)
assertEquals("tt4574334", media.ids.imdb)
assertEquals(TrackingMediaKind.SHOW, media.kind)
assertEquals(2016, media.year)
assertEquals(2, media.episode?.season)
assertEquals(7, media.episode?.number)
}
}

View file

@ -0,0 +1,47 @@
package com.nuvio.app.features.tracking
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class TrackingScrobbleCoordinatorTest {
@Test
fun `fanout isolates one provider failure`() = runBlocking {
val successful = FakeScrobbler(TrackingProviderId.TRAKT)
val failing = FakeScrobbler(TrackingProviderId.SIMKL, failure = IllegalStateException("offline"))
val event = TrackingScrobbleEvent(
media = TrackingMediaReference(
kind = TrackingMediaKind.MOVIE,
ids = TrackingExternalIds(imdb = "tt0111161"),
),
progressPercent = 42.5,
)
val failures = dispatchTrackingScrobble(
scrobblers = listOf(successful, failing),
profileId = 2,
action = TrackingScrobbleAction.PAUSE,
event = event,
)
assertEquals(1, successful.callCount)
assertEquals(1, failing.callCount)
assertEquals(listOf(TrackingProviderId.SIMKL), failures.map(TrackingScrobbleFailure::providerId))
}
private class FakeScrobbler(
override val providerId: TrackingProviderId,
private val failure: Throwable? = null,
) : TrackingScrobbler {
var callCount: Int = 0
override suspend fun scrobble(
profileId: Int,
action: TrackingScrobbleAction,
event: TrackingScrobbleEvent,
) {
callCount += 1
failure?.let { throw it }
}
}
}