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

View file

@ -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 ────────────────────────────────────────────────────────

View file

@ -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

View file

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

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