From 2dffff3520bb82b611d4667eb0f52aa113749b71 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:59:16 +0530 Subject: [PATCH] feat(tracking): fan out real player scrobbles --- .../commonMain/kotlin/com/nuvio/app/App.kt | 21 ++-- .../tracking/TrackingProviderBootstrap.kt | 2 + .../player/PlayerScreenRuntimeEffects.kt | 15 +-- .../PlayerScreenRuntimePlaybackActions.kt | 99 +++++++++++-------- .../player/PlayerScreenRuntimeState.kt | 5 +- .../features/simkl/SimklMutationRepository.kt | 8 +- .../app/features/simkl/SimklProjections.kt | 28 ++++++ .../app/features/tracking/TrackingMedia.kt | 27 +++++ .../tracking/TrackingScrobbleCoordinator.kt | 58 +++++++++++ .../features/trakt/TraktScrobbleRepository.kt | 72 +++++++++++++- .../features/tracking/TrackingMediaTest.kt | 20 ++++ .../TrackingScrobbleCoordinatorTest.kt | 47 +++++++++ 12 files changed, 338 insertions(+), 64 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index dd8cc3e73..dd53197a7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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(), + ), ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt index 26f458489..34a9f27af 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/tracking/TrackingProviderBootstrap.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index 02df155ae..b06d0b7c1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt index 516548999..983b6637f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -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 - } - } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index 43b4977a8..b0ca89161 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -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(null) + var currentTrackingMedia by mutableStateOf(null) var showSourcesPanel by mutableStateOf(false) var showEpisodesPanel by mutableStateOf(false) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 2dc0e15cb..2f97cabc4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index 63b0fb225..54c739f43 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -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()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt index 8b23654aa..16bbeeb7d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingMedia.kt @@ -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}") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt new file mode 100644 index 000000000..23e83be78 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinator.kt @@ -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 { + 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, + profileId: Int, + action: TrackingScrobbleAction, + event: TrackingScrobbleEvent, +): List = 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() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 36f6ba59d..55e7d3045 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -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, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt index 009a7c4c1..f6c5d1bec 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingMediaTest.kt @@ -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) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt new file mode 100644 index 000000000..a70b1ccc4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tracking/TrackingScrobbleCoordinatorTest.kt @@ -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 } + } + } +}