diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 785ecb6a..e155ef88 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -357,9 +357,10 @@ fun PlayerScreen( .coerceIn(0f, 100f) } - fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem( + suspend fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem( contentType = contentType ?: parentMetaType, parentMetaId = parentMetaId, + videoId = activeVideoId, title = title, seasonNumber = activeSeasonNumber, episodeNumber = activeEpisodeNumber, @@ -367,11 +368,15 @@ fun PlayerScreen( ) fun emitTraktScrobbleStart() { - val item = currentTraktScrobbleItem() ?: return if (hasRequestedScrobbleStartForCurrentItem) return hasRequestedScrobbleStartForCurrentItem = true scope.launch { + val item = currentTraktScrobbleItem() + if (item == null) { + hasRequestedScrobbleStartForCurrentItem = false + return@launch + } TraktScrobbleRepository.scrobbleStart( item = item, progressPercent = currentPlaybackProgressPercent(), @@ -380,12 +385,12 @@ fun PlayerScreen( } fun emitTraktScrobbleStop(progressPercent: Float? = null) { - val item = currentTraktScrobbleItem() ?: return val provided = progressPercent if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return val percent = provided ?: currentPlaybackProgressPercent() scope.launch { + val item = currentTraktScrobbleItem() ?: return@launch TraktScrobbleRepository.scrobbleStop( item = item, progressPercent = percent, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt index 50fa7baf..5ecb0492 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt @@ -51,6 +51,7 @@ object TraktEpisodeMappingService { videoId: String?, season: Int?, episode: Int?, + episodeTitle: String? = null, ): EpisodeMappingEntry? { val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null cacheMutex.withLock { @@ -77,7 +78,7 @@ object TraktEpisodeMappingService { requestedSeason = requestedSeason, requestedEpisode = requestedEpisode, requestedVideoId = videoId, - requestedTitle = null, + requestedTitle = episodeTitle, addonEpisodes = addonEpisodes, traktEpisodes = traktEpisodes, ) ?: return null @@ -176,18 +177,18 @@ object TraktEpisodeMappingService { // ── Season structure comparison ─────────────────────────────────────── - private fun hasSameSeasonStructure( + internal fun hasSameSeasonStructure( addonEpisodes: List, traktEpisodes: List, ): Boolean { - val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season } - val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season } - return addonSeasons == traktSeasons + val addonPerSeason = addonEpisodes.groupBy { it.season }.mapValues { it.value.size } + val traktPerSeason = traktEpisodes.groupBy { it.season }.mapValues { it.value.size } + return addonPerSeason == traktPerSeason } // ── Forward mapping: addon → Trakt ────────────────────────────────── - private fun remapEpisodeByTitleOrIndex( + internal fun remapEpisodeByTitleOrIndex( requestedSeason: Int, requestedEpisode: Int, requestedVideoId: String?, @@ -195,63 +196,72 @@ object TraktEpisodeMappingService { addonEpisodes: List, traktEpisodes: List, ): EpisodeMappingEntry? { - // Find the addon episode entry - val addonEntry = addonEpisodes.firstOrNull { - it.season == requestedSeason && it.episode == requestedEpisode - } ?: addonEpisodes.firstOrNull { - !requestedVideoId.isNullOrBlank() && it.videoId == requestedVideoId - } ?: return null - - // Try title match first - val titleToMatch = addonEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle - if (!titleToMatch.isNullOrBlank()) { - val titleMatch = traktEpisodes.firstOrNull { target -> - !target.title.isNullOrBlank() && - normalizeTitle(target.title) == normalizeTitle(titleToMatch) - } - if (titleMatch != null) { - return titleMatch - } - } - - // Fallback: global index mapping - val addonIndex = addonEpisodes.indexOf(addonEntry) - if (addonIndex < 0 || addonIndex >= traktEpisodes.size) return null - - return traktEpisodes[addonIndex] + return remapEpisodeBetweenLists( + requestedSeason = requestedSeason, + requestedEpisode = requestedEpisode, + requestedVideoId = requestedVideoId, + requestedTitle = requestedTitle, + sourceEpisodes = addonEpisodes, + targetEpisodes = traktEpisodes, + ) } // ── Reverse mapping: Trakt → addon ────────────────────────────────── - private fun reverseRemapEpisodeByTitleOrIndex( + internal fun reverseRemapEpisodeByTitleOrIndex( requestedSeason: Int, requestedEpisode: Int, requestedTitle: String?, addonEpisodes: List, traktEpisodes: List, ): EpisodeMappingEntry? { - // Find the Trakt episode entry - val traktEntry = traktEpisodes.firstOrNull { - it.season == requestedSeason && it.episode == requestedEpisode - } ?: return null + return remapEpisodeBetweenLists( + requestedSeason = requestedSeason, + requestedEpisode = requestedEpisode, + requestedVideoId = null, + requestedTitle = requestedTitle, + sourceEpisodes = traktEpisodes, + targetEpisodes = addonEpisodes, + ) + } - // Try title match first - val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle - if (!titleToMatch.isNullOrBlank()) { - val titleMatch = addonEpisodes.firstOrNull { target -> - !target.title.isNullOrBlank() && - normalizeTitle(target.title) == normalizeTitle(titleToMatch) + private fun remapEpisodeBetweenLists( + requestedSeason: Int, + requestedEpisode: Int, + requestedVideoId: String?, + requestedTitle: String?, + sourceEpisodes: List, + targetEpisodes: List, + ): EpisodeMappingEntry? { + if (sourceEpisodes.isEmpty() || targetEpisodes.isEmpty()) return null + + val orderedSourceEpisodes = sourceEpisodes + .sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode)) + val orderedTargetEpisodes = targetEpisodes + .sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode)) + + val currentSourceEpisode = requestedVideoId + ?.takeIf { it.isNotBlank() } + ?.let { videoId -> orderedSourceEpisodes.firstOrNull { it.videoId == videoId } } + ?: orderedSourceEpisodes.firstOrNull { + it.season == requestedSeason && it.episode == requestedEpisode } - if (titleMatch != null) { - return titleMatch + ?: return null + + val normalizedTitle = normalizeEpisodeTitle(requestedTitle ?: currentSourceEpisode.title) + if (isUsefulEpisodeTitle(normalizedTitle)) { + val titleMatches = orderedTargetEpisodes.filter { + normalizeEpisodeTitle(it.title) == normalizedTitle + } + if (titleMatches.size == 1) { + return titleMatches.first() } } - // Fallback: global index mapping - val traktIndex = traktEpisodes.indexOf(traktEntry) - if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null + val sourceIndex = orderedSourceEpisodes.indexOf(currentSourceEpisode) + if (sourceIndex !in orderedTargetEpisodes.indices) return null - return addonEpisodes[traktIndex] + return orderedTargetEpisodes[sourceIndex] } // ── Addon episodes fetching (with dedup) ─────────────────────────── @@ -396,7 +406,7 @@ object TraktEpisodeMappingService { return when { !contentIds.imdb.isNullOrBlank() -> contentIds.imdb contentIds.trakt != null -> contentIds.trakt.toString() - contentIds.tmdb != null -> contentIds.tmdb.toString() + !contentIds.slug.isNullOrBlank() -> contentIds.slug else -> null } } @@ -405,13 +415,13 @@ object TraktEpisodeMappingService { return when { !videoIds.imdb.isNullOrBlank() -> videoIds.imdb videoIds.trakt != null -> videoIds.trakt.toString() - videoIds.tmdb != null -> videoIds.tmdb.toString() + !videoIds.slug.isNullOrBlank() -> videoIds.slug else -> null } } private fun TraktExternalIds.hasAnyId(): Boolean = - !imdb.isNullOrBlank() || trakt != null || tmdb != null + !imdb.isNullOrBlank() || trakt != null || !slug.isNullOrBlank() private fun cacheKey( contentId: String?, @@ -461,9 +471,22 @@ object TraktEpisodeMappingService { .toList() } - private fun normalizeTitle(title: String?): String = - title.orEmpty().trim().lowercase() - .replace(Regex("[^a-z0-9]"), "") + private fun normalizeEpisodeTitle(title: String?): String { + return title + .orEmpty() + .lowercase() + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + .replace(Regex("\\s+"), " ") + } + + private fun isUsefulEpisodeTitle(normalizedTitle: String): Boolean { + if (normalizedTitle.isBlank()) return false + if (normalizedTitle.matches(Regex("episode \\d+"))) return false + if (normalizedTitle.matches(Regex("ep \\d+"))) return false + if (normalizedTitle.matches(Regex("e \\d+"))) return false + return true + } } // ── Data classes ──────────────────────────────────────────────────────── diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index de3e429f..dc43c983 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -475,27 +475,27 @@ object TraktProgressRepository { var resolvedEpisode = entry.episodeNumber val episode = if (resolvedSeason != null && resolvedEpisode != null) { - // Try direct match first val directMatch = meta.videos.firstOrNull { video -> video.season == resolvedSeason && video.episode == resolvedEpisode } if (directMatch != null) { directMatch } else { - // Fallback: reverse-remap from Trakt numbering to addon numbering - val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season } - if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) { - val sorted = meta.videos - .filter { it.season != null && it.episode != null } - .sortedWith(compareBy({ it.season }, { it.episode })) - val globalIndex = resolvedEpisode!! - 1 - if (globalIndex in sorted.indices) { - val remapped = sorted[globalIndex] - resolvedSeason = remapped.season - resolvedEpisode = remapped.episode - remapped - } else null - } else null + val remapped = resolveAddonEpisodeProgress( + contentId = entry.parentMetaId, + season = resolvedSeason, + episode = resolvedEpisode, + episodeTitle = entry.episodeTitle, + ) + if (remapped != null) { + resolvedSeason = remapped.season + resolvedEpisode = remapped.episode + meta.videos.firstOrNull { video -> + video.season == remapped.season && video.episode == remapped.episode + } + } else { + null + } } } else { null @@ -540,7 +540,7 @@ object TraktProgressRepository { ).normalizedCompletion() } - private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? { + private suspend fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? { val show = item.show ?: return null val episode = item.episode ?: return null val season = episode.season ?: return null @@ -551,6 +551,14 @@ object TraktProgressRepository { val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null if (progressPercent <= 0f) return null + val resolvedEpisode = resolveAddonEpisodeProgress( + contentId = parentMetaId, + season = season, + episode = number, + episodeTitle = episode.title, + ) + val resolvedSeason = resolvedEpisode?.season ?: season + val resolvedNumber = resolvedEpisode?.episode ?: number return WatchProgressEntry( contentType = "series", @@ -558,14 +566,14 @@ object TraktProgressRepository { parentMetaType = "series", videoId = buildPlaybackVideoId( parentMetaId = parentMetaId, - seasonNumber = season, - episodeNumber = number, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" }, ), title = show.title ?: parentMetaId, - seasonNumber = season, - episodeNumber = number, - episodeTitle = episode.title, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, + episodeTitle = resolvedEpisode?.title ?: episode.title, lastPositionMs = 0L, durationMs = 0L, lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), @@ -575,7 +583,7 @@ object TraktProgressRepository { ).normalizedCompletion() } - private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? { + private suspend fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? { val show = item.show ?: return null val episode = item.episode ?: return null val season = episode.season ?: return null @@ -583,6 +591,14 @@ object TraktProgressRepository { val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title) if (parentMetaId.isBlank()) return null + val resolvedEpisode = resolveAddonEpisodeProgress( + contentId = parentMetaId, + season = season, + episode = number, + episodeTitle = episode.title, + ) + val resolvedSeason = resolvedEpisode?.season ?: season + val resolvedNumber = resolvedEpisode?.episode ?: number return WatchProgressEntry( contentType = "series", @@ -590,14 +606,14 @@ object TraktProgressRepository { parentMetaType = "series", videoId = buildPlaybackVideoId( parentMetaId = parentMetaId, - seasonNumber = season, - episodeNumber = number, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" }, ), title = show.title ?: parentMetaId, - seasonNumber = season, - episodeNumber = number, - episodeTitle = episode.title, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, + episodeTitle = resolvedEpisode?.title ?: episode.title, lastPositionMs = 1L, durationMs = 1L, lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), @@ -627,7 +643,7 @@ object TraktProgressRepository { ) } - private fun mapWatchedShowSeed( + private suspend fun mapWatchedShowSeed( item: TraktWatchedShowItem, useFurthestEpisode: Boolean, ): WatchProgressEntry? { @@ -670,6 +686,14 @@ object TraktProgressRepository { ) }, ) ?: return null + val resolvedEpisode = resolveAddonEpisodeProgress( + contentId = parentMetaId, + season = completedEpisode.season, + episode = completedEpisode.episode, + episodeTitle = null, + ) + val resolvedSeason = resolvedEpisode?.season ?: completedEpisode.season + val resolvedNumber = resolvedEpisode?.episode ?: completedEpisode.episode return WatchProgressEntry( contentType = "series", @@ -677,13 +701,14 @@ object TraktProgressRepository { parentMetaType = "series", videoId = buildPlaybackVideoId( parentMetaId = parentMetaId, - seasonNumber = completedEpisode.season, - episodeNumber = completedEpisode.episode, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, fallbackVideoId = null, ), title = show.title ?: parentMetaId, - seasonNumber = completedEpisode.season, - episodeNumber = completedEpisode.episode, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, + episodeTitle = resolvedEpisode?.title, lastPositionMs = 1L, durationMs = 1L, lastUpdatedEpochMs = completedEpisode.watchedAt, @@ -710,6 +735,26 @@ object TraktProgressRepository { ?.let { return it } return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L) } + + private suspend fun resolveAddonEpisodeProgress( + contentId: String, + season: Int, + episode: Int, + episodeTitle: String?, + ): EpisodeMappingEntry? { + return runCatching { + TraktEpisodeMappingService.resolveAddonEpisodeMapping( + contentId = contentId, + contentType = "series", + season = season, + episode = episode, + episodeTitle = episodeTitle, + ) + }.onFailure { error -> + if (error is CancellationException) throw error + log.w { "resolveAddonEpisodeProgress failed for $contentId s=$season e=$episode: ${error.message}" } + }.getOrNull() + } } @Serializable 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 217e2f70..69445d7d 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 @@ -63,9 +63,10 @@ internal object TraktScrobbleRepository { sendScrobble(action = "stop", item = item, progressPercent = progressPercent) } - fun buildItem( + suspend fun buildItem( contentType: String, parentMetaId: String, + videoId: String?, title: String?, seasonNumber: Int?, episodeNumber: Int?, @@ -81,12 +82,20 @@ internal object TraktScrobbleRepository { seasonNumber != null && episodeNumber != null ) { + val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping( + contentId = parentMetaId, + contentType = contentType, + videoId = videoId, + season = seasonNumber, + episode = episodeNumber, + episodeTitle = episodeTitle, + ) TraktScrobbleItem.Episode( showTitle = title, showYear = parsedYear, showIds = ids, - season = seasonNumber, - number = episodeNumber, + season = mappedEpisode?.season ?: seasonNumber, + number = mappedEpisode?.episode ?: episodeNumber, episodeTitle = episodeTitle, ) } else { @@ -247,6 +256,9 @@ internal object TraktScrobbleRepository { val isSameAction = last.action == action val isSameItem = last.itemKey == itemKey val isNearProgress = abs(last.progress - progress) <= progressWindow + if (action == "stop" && last.action == "start" && isSameItem) { + return false + } return isSameWindow && isSameAction && isSameItem && isNearProgress } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt new file mode 100644 index 00000000..295668b8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt @@ -0,0 +1,215 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TraktEpisodeMappingServiceTest { + + @Test + fun `same structure compares per-season episode counts`() { + val addon = listOf( + episode(1, 1), + episode(1, 2), + episode(2, 1), + ) + val sameSeasonsDifferentCounts = listOf( + episode(1, 1), + episode(2, 1), + episode(2, 2), + ) + val sameCounts = listOf( + episode(1, 1), + episode(1, 2), + episode(2, 1), + ) + + assertFalse(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameSeasonsDifferentCounts)) + assertTrue(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameCounts)) + } + + @Test + fun `forward mapping uses global sorted index for anime numbering`() { + val addon = listOf( + episode(1, 1, videoId = "show:1:1"), + episode(1, 2, videoId = "show:1:2"), + episode(2, 1, videoId = "show:2:1"), + episode(2, 2, videoId = "show:2:2"), + ) + val trakt = listOf( + episode(1, 1), + episode(1, 2), + episode(1, 3), + episode(1, 4), + ) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 2, + requestedEpisode = 1, + requestedVideoId = null, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(1, mapped?.season) + assertEquals(3, mapped?.episode) + } + + @Test + fun `reverse mapping uses global sorted index for Trakt absolute numbering`() { + val addon = listOf( + episode(1, 1), + episode(1, 2), + episode(2, 1), + episode(2, 2), + ) + val trakt = listOf( + episode(1, 1), + episode(1, 2), + episode(1, 3), + episode(1, 4), + ) + + val mapped = TraktEpisodeMappingService.reverseRemapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 3, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(2, mapped?.season) + assertEquals(1, mapped?.episode) + } + + @Test + fun `unique normalized title wins over index`() { + val addon = listOf( + episode(1, 1, title = "The Storm"), + episode(1, 2, title = "Aftermath"), + ) + val trakt = listOf( + episode(1, 1, title = "Aftermath"), + episode(1, 2, title = "The Storm!"), + ) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 1, + requestedVideoId = null, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(1, mapped?.season) + assertEquals(2, mapped?.episode) + } + + @Test + fun `generic title falls back to index`() { + val addon = listOf( + episode(1, 1, title = "Episode 1"), + episode(2, 1, title = "Actual Title"), + ) + val trakt = listOf( + episode(1, 1, title = "Actual Title"), + episode(1, 2, title = "Episode 1"), + ) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 1, + requestedVideoId = null, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(1, mapped?.season) + assertEquals(1, mapped?.episode) + } + + @Test + fun `duplicate title falls back to index`() { + val addon = listOf( + episode(1, 1, title = "Pilot"), + episode(2, 1, title = "Other"), + ) + val trakt = listOf( + episode(1, 1, title = "Pilot"), + episode(1, 2, title = "Pilot"), + ) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 1, + requestedVideoId = null, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(1, mapped?.season) + assertEquals(1, mapped?.episode) + } + + @Test + fun `video id selects source episode before season episode`() { + val addon = listOf( + episode(1, 1, videoId = "show:1:1"), + episode(2, 1, videoId = "show:2:1"), + ) + val trakt = listOf( + episode(1, 1), + episode(1, 2), + ) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 1, + requestedVideoId = "show:2:1", + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(1, mapped?.season) + assertEquals(2, mapped?.episode) + } + + @Test + fun `index outside target range returns null`() { + val addon = listOf( + episode(1, 1), + episode(1, 2), + ) + val trakt = listOf(episode(1, 1)) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 2, + requestedVideoId = null, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertNull(mapped) + } + + private fun episode( + season: Int, + episode: Int, + title: String? = null, + videoId: String? = null, + ) = EpisodeMappingEntry( + season = season, + episode = episode, + title = title, + videoId = videoId, + ) +}