mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
ref: adjust trakt episode remapping
This commit is contained in:
parent
96d0b0703e
commit
c8c1dea761
5 changed files with 391 additions and 91 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<EpisodeMappingEntry>,
|
||||
traktEpisodes: List<EpisodeMappingEntry>,
|
||||
): 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<EpisodeMappingEntry>,
|
||||
traktEpisodes: List<EpisodeMappingEntry>,
|
||||
): 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<EpisodeMappingEntry>,
|
||||
traktEpisodes: List<EpisodeMappingEntry>,
|
||||
): 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<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 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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue