ref: adjust trakt episode remapping

This commit is contained in:
tapframe 2026-05-10 09:36:49 +05:30
parent 96d0b0703e
commit c8c1dea761
5 changed files with 391 additions and 91 deletions

View file

@ -357,9 +357,10 @@ fun PlayerScreen(
.coerceIn(0f, 100f) .coerceIn(0f, 100f)
} }
fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem( suspend fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem(
contentType = contentType ?: parentMetaType, contentType = contentType ?: parentMetaType,
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
videoId = activeVideoId,
title = title, title = title,
seasonNumber = activeSeasonNumber, seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber, episodeNumber = activeEpisodeNumber,
@ -367,11 +368,15 @@ fun PlayerScreen(
) )
fun emitTraktScrobbleStart() { fun emitTraktScrobbleStart() {
val item = currentTraktScrobbleItem() ?: return
if (hasRequestedScrobbleStartForCurrentItem) return if (hasRequestedScrobbleStartForCurrentItem) return
hasRequestedScrobbleStartForCurrentItem = true hasRequestedScrobbleStartForCurrentItem = true
scope.launch { scope.launch {
val item = currentTraktScrobbleItem()
if (item == null) {
hasRequestedScrobbleStartForCurrentItem = false
return@launch
}
TraktScrobbleRepository.scrobbleStart( TraktScrobbleRepository.scrobbleStart(
item = item, item = item,
progressPercent = currentPlaybackProgressPercent(), progressPercent = currentPlaybackProgressPercent(),
@ -380,12 +385,12 @@ fun PlayerScreen(
} }
fun emitTraktScrobbleStop(progressPercent: Float? = null) { fun emitTraktScrobbleStop(progressPercent: Float? = null) {
val item = currentTraktScrobbleItem() ?: return
val provided = progressPercent val provided = progressPercent
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
val percent = provided ?: currentPlaybackProgressPercent() val percent = provided ?: currentPlaybackProgressPercent()
scope.launch { scope.launch {
val item = currentTraktScrobbleItem() ?: return@launch
TraktScrobbleRepository.scrobbleStop( TraktScrobbleRepository.scrobbleStop(
item = item, item = item,
progressPercent = percent, progressPercent = percent,

View file

@ -51,6 +51,7 @@ object TraktEpisodeMappingService {
videoId: String?, videoId: String?,
season: Int?, season: Int?,
episode: Int?, episode: Int?,
episodeTitle: String? = null,
): EpisodeMappingEntry? { ): EpisodeMappingEntry? {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
cacheMutex.withLock { cacheMutex.withLock {
@ -77,7 +78,7 @@ object TraktEpisodeMappingService {
requestedSeason = requestedSeason, requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode, requestedEpisode = requestedEpisode,
requestedVideoId = videoId, requestedVideoId = videoId,
requestedTitle = null, requestedTitle = episodeTitle,
addonEpisodes = addonEpisodes, addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes, traktEpisodes = traktEpisodes,
) ?: return null ) ?: return null
@ -176,18 +177,18 @@ object TraktEpisodeMappingService {
// ── Season structure comparison ─────────────────────────────────────── // ── Season structure comparison ───────────────────────────────────────
private fun hasSameSeasonStructure( internal fun hasSameSeasonStructure(
addonEpisodes: List<EpisodeMappingEntry>, addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>, traktEpisodes: List<EpisodeMappingEntry>,
): Boolean { ): Boolean {
val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season } val addonPerSeason = addonEpisodes.groupBy { it.season }.mapValues { it.value.size }
val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season } val traktPerSeason = traktEpisodes.groupBy { it.season }.mapValues { it.value.size }
return addonSeasons == traktSeasons return addonPerSeason == traktPerSeason
} }
// ── Forward mapping: addon → Trakt ────────────────────────────────── // ── Forward mapping: addon → Trakt ──────────────────────────────────
private fun remapEpisodeByTitleOrIndex( internal fun remapEpisodeByTitleOrIndex(
requestedSeason: Int, requestedSeason: Int,
requestedEpisode: Int, requestedEpisode: Int,
requestedVideoId: String?, requestedVideoId: String?,
@ -195,63 +196,72 @@ object TraktEpisodeMappingService {
addonEpisodes: List<EpisodeMappingEntry>, addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>, traktEpisodes: List<EpisodeMappingEntry>,
): EpisodeMappingEntry? { ): EpisodeMappingEntry? {
// Find the addon episode entry return remapEpisodeBetweenLists(
val addonEntry = addonEpisodes.firstOrNull { requestedSeason = requestedSeason,
it.season == requestedSeason && it.episode == requestedEpisode requestedEpisode = requestedEpisode,
} ?: addonEpisodes.firstOrNull { requestedVideoId = requestedVideoId,
!requestedVideoId.isNullOrBlank() && it.videoId == requestedVideoId requestedTitle = requestedTitle,
} ?: return null sourceEpisodes = addonEpisodes,
targetEpisodes = traktEpisodes,
// 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]
} }
// ── Reverse mapping: Trakt → addon ────────────────────────────────── // ── Reverse mapping: Trakt → addon ──────────────────────────────────
private fun reverseRemapEpisodeByTitleOrIndex( internal fun reverseRemapEpisodeByTitleOrIndex(
requestedSeason: Int, requestedSeason: Int,
requestedEpisode: Int, requestedEpisode: Int,
requestedTitle: String?, requestedTitle: String?,
addonEpisodes: List<EpisodeMappingEntry>, addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>, traktEpisodes: List<EpisodeMappingEntry>,
): EpisodeMappingEntry? { ): EpisodeMappingEntry? {
// Find the Trakt episode entry return remapEpisodeBetweenLists(
val traktEntry = traktEpisodes.firstOrNull { requestedSeason = requestedSeason,
it.season == requestedSeason && it.episode == requestedEpisode requestedEpisode = requestedEpisode,
} ?: return null requestedVideoId = null,
requestedTitle = requestedTitle,
sourceEpisodes = traktEpisodes,
targetEpisodes = addonEpisodes,
)
}
// Try title match first private fun remapEpisodeBetweenLists(
val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle requestedSeason: Int,
if (!titleToMatch.isNullOrBlank()) { requestedEpisode: Int,
val titleMatch = addonEpisodes.firstOrNull { target -> requestedVideoId: String?,
!target.title.isNullOrBlank() && requestedTitle: String?,
normalizeTitle(target.title) == normalizeTitle(titleToMatch) sourceEpisodes: List<EpisodeMappingEntry>,
targetEpisodes: List<EpisodeMappingEntry>,
): 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 null
return titleMatch
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 sourceIndex = orderedSourceEpisodes.indexOf(currentSourceEpisode)
val traktIndex = traktEpisodes.indexOf(traktEntry) if (sourceIndex !in orderedTargetEpisodes.indices) return null
if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null
return addonEpisodes[traktIndex] return orderedTargetEpisodes[sourceIndex]
} }
// ── Addon episodes fetching (with dedup) ─────────────────────────── // ── Addon episodes fetching (with dedup) ───────────────────────────
@ -396,7 +406,7 @@ object TraktEpisodeMappingService {
return when { return when {
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb !contentIds.imdb.isNullOrBlank() -> contentIds.imdb
contentIds.trakt != null -> contentIds.trakt.toString() contentIds.trakt != null -> contentIds.trakt.toString()
contentIds.tmdb != null -> contentIds.tmdb.toString() !contentIds.slug.isNullOrBlank() -> contentIds.slug
else -> null else -> null
} }
} }
@ -405,13 +415,13 @@ object TraktEpisodeMappingService {
return when { return when {
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb !videoIds.imdb.isNullOrBlank() -> videoIds.imdb
videoIds.trakt != null -> videoIds.trakt.toString() videoIds.trakt != null -> videoIds.trakt.toString()
videoIds.tmdb != null -> videoIds.tmdb.toString() !videoIds.slug.isNullOrBlank() -> videoIds.slug
else -> null else -> null
} }
} }
private fun TraktExternalIds.hasAnyId(): Boolean = private fun TraktExternalIds.hasAnyId(): Boolean =
!imdb.isNullOrBlank() || trakt != null || tmdb != null !imdb.isNullOrBlank() || trakt != null || !slug.isNullOrBlank()
private fun cacheKey( private fun cacheKey(
contentId: String?, contentId: String?,
@ -461,9 +471,22 @@ object TraktEpisodeMappingService {
.toList() .toList()
} }
private fun normalizeTitle(title: String?): String = private fun normalizeEpisodeTitle(title: String?): String {
title.orEmpty().trim().lowercase() return title
.replace(Regex("[^a-z0-9]"), "") .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 ──────────────────────────────────────────────────────── // ── Data classes ────────────────────────────────────────────────────────

View file

@ -475,27 +475,27 @@ object TraktProgressRepository {
var resolvedEpisode = entry.episodeNumber var resolvedEpisode = entry.episodeNumber
val episode = if (resolvedSeason != null && resolvedEpisode != null) { val episode = if (resolvedSeason != null && resolvedEpisode != null) {
// Try direct match first
val directMatch = meta.videos.firstOrNull { video -> val directMatch = meta.videos.firstOrNull { video ->
video.season == resolvedSeason && video.episode == resolvedEpisode video.season == resolvedSeason && video.episode == resolvedEpisode
} }
if (directMatch != null) { if (directMatch != null) {
directMatch directMatch
} else { } else {
// Fallback: reverse-remap from Trakt numbering to addon numbering val remapped = resolveAddonEpisodeProgress(
val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season } contentId = entry.parentMetaId,
if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) { season = resolvedSeason,
val sorted = meta.videos episode = resolvedEpisode,
.filter { it.season != null && it.episode != null } episodeTitle = entry.episodeTitle,
.sortedWith(compareBy({ it.season }, { it.episode })) )
val globalIndex = resolvedEpisode!! - 1 if (remapped != null) {
if (globalIndex in sorted.indices) { resolvedSeason = remapped.season
val remapped = sorted[globalIndex] resolvedEpisode = remapped.episode
resolvedSeason = remapped.season meta.videos.firstOrNull { video ->
resolvedEpisode = remapped.episode video.season == remapped.season && video.episode == remapped.episode
remapped }
} else null } else {
} else null null
}
} }
} else { } else {
null null
@ -540,7 +540,7 @@ object TraktProgressRepository {
).normalizedCompletion() ).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 show = item.show ?: return null
val episode = item.episode ?: return null val episode = item.episode ?: return null
val season = episode.season ?: return null val season = episode.season ?: return null
@ -551,6 +551,14 @@ object TraktProgressRepository {
val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null
if (progressPercent <= 0f) 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( return WatchProgressEntry(
contentType = "series", contentType = "series",
@ -558,14 +566,14 @@ object TraktProgressRepository {
parentMetaType = "series", parentMetaType = "series",
videoId = buildPlaybackVideoId( videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
seasonNumber = season, seasonNumber = resolvedSeason,
episodeNumber = number, episodeNumber = resolvedNumber,
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" }, fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
), ),
title = show.title ?: parentMetaId, title = show.title ?: parentMetaId,
seasonNumber = season, seasonNumber = resolvedSeason,
episodeNumber = number, episodeNumber = resolvedNumber,
episodeTitle = episode.title, episodeTitle = resolvedEpisode?.title ?: episode.title,
lastPositionMs = 0L, lastPositionMs = 0L,
durationMs = 0L, durationMs = 0L,
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
@ -575,7 +583,7 @@ object TraktProgressRepository {
).normalizedCompletion() ).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 show = item.show ?: return null
val episode = item.episode ?: return null val episode = item.episode ?: return null
val season = episode.season ?: return null val season = episode.season ?: return null
@ -583,6 +591,14 @@ object TraktProgressRepository {
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title) val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
if (parentMetaId.isBlank()) return null 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( return WatchProgressEntry(
contentType = "series", contentType = "series",
@ -590,14 +606,14 @@ object TraktProgressRepository {
parentMetaType = "series", parentMetaType = "series",
videoId = buildPlaybackVideoId( videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
seasonNumber = season, seasonNumber = resolvedSeason,
episodeNumber = number, episodeNumber = resolvedNumber,
fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" }, fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" },
), ),
title = show.title ?: parentMetaId, title = show.title ?: parentMetaId,
seasonNumber = season, seasonNumber = resolvedSeason,
episodeNumber = number, episodeNumber = resolvedNumber,
episodeTitle = episode.title, episodeTitle = resolvedEpisode?.title ?: episode.title,
lastPositionMs = 1L, lastPositionMs = 1L,
durationMs = 1L, durationMs = 1L,
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
@ -627,7 +643,7 @@ object TraktProgressRepository {
) )
} }
private fun mapWatchedShowSeed( private suspend fun mapWatchedShowSeed(
item: TraktWatchedShowItem, item: TraktWatchedShowItem,
useFurthestEpisode: Boolean, useFurthestEpisode: Boolean,
): WatchProgressEntry? { ): WatchProgressEntry? {
@ -670,6 +686,14 @@ object TraktProgressRepository {
) )
}, },
) ?: return null ) ?: 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( return WatchProgressEntry(
contentType = "series", contentType = "series",
@ -677,13 +701,14 @@ object TraktProgressRepository {
parentMetaType = "series", parentMetaType = "series",
videoId = buildPlaybackVideoId( videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
seasonNumber = completedEpisode.season, seasonNumber = resolvedSeason,
episodeNumber = completedEpisode.episode, episodeNumber = resolvedNumber,
fallbackVideoId = null, fallbackVideoId = null,
), ),
title = show.title ?: parentMetaId, title = show.title ?: parentMetaId,
seasonNumber = completedEpisode.season, seasonNumber = resolvedSeason,
episodeNumber = completedEpisode.episode, episodeNumber = resolvedNumber,
episodeTitle = resolvedEpisode?.title,
lastPositionMs = 1L, lastPositionMs = 1L,
durationMs = 1L, durationMs = 1L,
lastUpdatedEpochMs = completedEpisode.watchedAt, lastUpdatedEpochMs = completedEpisode.watchedAt,
@ -710,6 +735,26 @@ object TraktProgressRepository {
?.let { return it } ?.let { return it }
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L) 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 @Serializable

View file

@ -63,9 +63,10 @@ internal object TraktScrobbleRepository {
sendScrobble(action = "stop", item = item, progressPercent = progressPercent) sendScrobble(action = "stop", item = item, progressPercent = progressPercent)
} }
fun buildItem( suspend fun buildItem(
contentType: String, contentType: String,
parentMetaId: String, parentMetaId: String,
videoId: String?,
title: String?, title: String?,
seasonNumber: Int?, seasonNumber: Int?,
episodeNumber: Int?, episodeNumber: Int?,
@ -81,12 +82,20 @@ internal object TraktScrobbleRepository {
seasonNumber != null && seasonNumber != null &&
episodeNumber != null episodeNumber != null
) { ) {
val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping(
contentId = parentMetaId,
contentType = contentType,
videoId = videoId,
season = seasonNumber,
episode = episodeNumber,
episodeTitle = episodeTitle,
)
TraktScrobbleItem.Episode( TraktScrobbleItem.Episode(
showTitle = title, showTitle = title,
showYear = parsedYear, showYear = parsedYear,
showIds = ids, showIds = ids,
season = seasonNumber, season = mappedEpisode?.season ?: seasonNumber,
number = episodeNumber, number = mappedEpisode?.episode ?: episodeNumber,
episodeTitle = episodeTitle, episodeTitle = episodeTitle,
) )
} else { } else {
@ -247,6 +256,9 @@ internal object TraktScrobbleRepository {
val isSameAction = last.action == action val isSameAction = last.action == action
val isSameItem = last.itemKey == itemKey val isSameItem = last.itemKey == itemKey
val isNearProgress = abs(last.progress - progress) <= progressWindow val isNearProgress = abs(last.progress - progress) <= progressWindow
if (action == "stop" && last.action == "start" && isSameItem) {
return false
}
return isSameWindow && isSameAction && isSameItem && isNearProgress return isSameWindow && isSameAction && isSameItem && isNearProgress
} }

View file

@ -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,
)
}