mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-09 08:27:54 +00:00
feat(tracking): fan out real player scrobbles
This commit is contained in:
parent
0f12e61430
commit
2dffff3520
12 changed files with 338 additions and 64 deletions
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue