Trakt Episode Mapping for Trakt Anime

This commit is contained in:
skoruppa 2026-04-30 10:41:51 +02:00
parent c4bdfce511
commit 49b8821f5a
6 changed files with 756 additions and 19 deletions

View file

@ -85,19 +85,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber = seasonNumber, seasonNumber = seasonNumber,
episodeNumber = episodeNumber, episodeNumber = episodeNumber,
) )
val candidates = sortedEpisodes var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
.dropWhile { episode ->
buildPlaybackVideoId( buildPlaybackVideoId(
content = WatchingContentRef(type = type, id = id), content = WatchingContentRef(type = type, id = id),
seasonNumber = episode.season, seasonNumber = episode.season,
episodeNumber = episode.episode, episodeNumber = episode.episode,
fallbackVideoId = episode.id, fallbackVideoId = episode.id,
) != watchedVideoId ) == watchedVideoId
} }
.drop(1)
// Fallback: if the seed wasn't found by season+episode (anime with absolute
// numbering on Trakt vs multi-season on addon), try global index matching.
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season }
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
if (globalIndex in sortedEpisodes.indices) {
watchedIndex = globalIndex
}
}
}
if (watchedIndex < 0) return null
val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season
val candidates = sortedEpisodes
.drop(watchedIndex + 1)
.filter { episode -> .filter { episode ->
shouldSurfaceNextEpisode( shouldSurfaceNextEpisode(
watchedSeasonNumber = seasonNumber, watchedSeasonNumber = watchedEpisodeSeason,
candidateSeasonNumber = episode.season, candidateSeasonNumber = episode.season,
todayIsoDate = todayIsoDate, todayIsoDate = todayIsoDate,
releasedDate = episode.released, releasedDate = episode.released,

View file

@ -0,0 +1,492 @@
package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaVideo
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
private const val BASE_URL = "https://api.trakt.tv"
/**
* Handles episode number remapping between addon metadata (which may use multi-season
* numbering for anime) and Trakt (which often uses absolute/single-season numbering).
*
* Example: An addon lists "Attack on Titan" as S1E1S1E25, S2E1S2E12, etc.
* Trakt may list it as S1E1S1E87 (absolute numbering).
*
* This service detects the mismatch and provides bidirectional mapping.
*/
object TraktEpisodeMappingService {
private val log = Logger.withTag("TraktEpMapSvc")
private val json = Json { ignoreUnknownKeys = true }
private val cacheMutex = Mutex()
private val mappingCache = mutableMapOf<String, EpisodeMappingEntry>()
private val reverseMappingCache = mutableMapOf<String, EpisodeMappingEntry>()
private val addonEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
private val traktEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
// In-flight dedup: prevents multiple concurrent coroutines from fetching
// the same show's addon episodes simultaneously.
private val addonEpisodesInFlight = mutableMapOf<String, CompletableDeferred<List<EpisodeMappingEntry>>>()
// ── Public API ────────────────────────────────────────────────────────
/**
* Resolves the Trakt-side season/episode for a given addon season/episode.
* Used when pushing watched status TO Trakt (forward mapping: addon Trakt).
*
* Returns null if no remapping is needed (same structure) or if mapping fails.
*/
suspend fun resolveEpisodeMapping(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?,
): EpisodeMappingEntry? {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
cacheMutex.withLock {
mappingCache[key]?.let { return it }
}
val requestedSeason = season ?: return null
val requestedEpisode = episode ?: return null
val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
if (addonEpisodes.isEmpty()) return null
val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = videoId) ?: return null
val traktEpisodes = getTraktEpisodes(showLookupId)
if (traktEpisodes.isEmpty()) return null
if (hasSameSeasonStructure(addonEpisodes, traktEpisodes)) {
return null
}
val mapped = remapEpisodeByTitleOrIndex(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedVideoId = videoId,
requestedTitle = null,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes,
) ?: return null
cacheMutex.withLock {
mappingCache[key] = mapped
}
return mapped
}
/**
* Resolves the addon-side season/episode for a given Trakt season/episode.
* Used when reading progress FROM Trakt to find the correct addon episode
* (reverse mapping: Trakt addon).
*
* Returns null if no remapping is needed or if mapping fails.
*/
suspend fun resolveAddonEpisodeMapping(
contentId: String?,
contentType: String?,
season: Int?,
episode: Int?,
episodeTitle: String? = null,
): EpisodeMappingEntry? {
val requestedSeason = season ?: return null
val requestedEpisode = episode ?: return null
val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
val reverseKey = reverseCacheKey(
contentId = resolvedContentId,
contentType = resolvedContentType,
season = requestedSeason,
episode = requestedEpisode,
title = episodeTitle,
)
cacheMutex.withLock {
reverseMappingCache[reverseKey]?.let { return it }
}
val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
if (addonEpisodes.isEmpty()) return null
val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = null) ?: return null
val traktEpisodes = getTraktEpisodes(showLookupId)
if (traktEpisodes.isEmpty()) return null
val addonHasEpisode = addonEpisodes.any {
it.season == requestedSeason && it.episode == requestedEpisode
}
if (addonHasEpisode && hasSameSeasonStructure(addonEpisodes, traktEpisodes)) {
return null
}
val mapped = reverseRemapEpisodeByTitleOrIndex(
requestedSeason = requestedSeason,
requestedEpisode = requestedEpisode,
requestedTitle = episodeTitle,
addonEpisodes = addonEpisodes,
traktEpisodes = traktEpisodes,
) ?: return null
cacheMutex.withLock {
reverseMappingCache[reverseKey] = mapped
}
return mapped
}
suspend fun getCachedEpisodeMapping(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?,
): EpisodeMappingEntry? {
val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
return cacheMutex.withLock { mappingCache[key] }
}
suspend fun prefetchEpisodeMapping(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?,
): EpisodeMappingEntry? {
return resolveEpisodeMapping(contentId, contentType, videoId, season, episode)
}
fun clearCache() {
mappingCache.clear()
reverseMappingCache.clear()
addonEpisodesCache.clear()
traktEpisodesCache.clear()
}
// ── Season structure comparison ───────────────────────────────────────
private 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
}
// ── Forward mapping: addon → Trakt ──────────────────────────────────
private fun remapEpisodeByTitleOrIndex(
requestedSeason: Int,
requestedEpisode: Int,
requestedVideoId: String?,
requestedTitle: String?,
addonEpisodes: List<EpisodeMappingEntry>,
traktEpisodes: List<EpisodeMappingEntry>,
): EpisodeMappingEntry? {
// Find the addon episode entry
val addonEntry = addonEpisodes.firstOrNull {
it.season == requestedSeason && it.episode == requestedEpisode
} ?: if (!requestedVideoId.isNullOrBlank()) {
addonEpisodes.firstOrNull { it.videoId == requestedVideoId }
} else null
?: 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]
}
// ── Reverse mapping: Trakt → addon ──────────────────────────────────
private 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
// 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)
}
if (titleMatch != null) {
return titleMatch
}
}
// Fallback: global index mapping
val traktIndex = traktEpisodes.indexOf(traktEntry)
if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null
return addonEpisodes[traktIndex]
}
// ── Addon episodes fetching (with dedup) ───────────────────────────
private suspend fun getAddonEpisodes(
contentId: String,
contentType: String,
): List<EpisodeMappingEntry> {
val cacheKey = addonEpisodesCacheKey(contentId, contentType)
// Fast path: cache hit
cacheMutex.withLock {
addonEpisodesCache[cacheKey]?.let { return it }
}
// Dedup: if another coroutine is already fetching this show, await its result.
val existingDeferred = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] }
if (existingDeferred != null) {
return try { existingDeferred.await() } catch (_: Exception) { emptyList() }
}
// Register ourselves as the in-flight fetcher.
val deferred = CompletableDeferred<List<EpisodeMappingEntry>>()
val weOwn = cacheMutex.withLock {
// Double-check: cache or another flight may have appeared while we waited.
addonEpisodesCache[cacheKey]?.let { return it }
if (addonEpisodesInFlight.containsKey(cacheKey)) {
false
} else {
addonEpisodesInFlight[cacheKey] = deferred
true
}
}
if (!weOwn) {
val other = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] }
return try { other?.await() ?: emptyList() } catch (_: Exception) { emptyList() }
}
return try {
val addonEpisodes = fetchAddonEpisodes(contentId, contentType)
if (addonEpisodes.isNotEmpty()) {
cacheMutex.withLock { addonEpisodesCache[cacheKey] = addonEpisodes }
}
deferred.complete(addonEpisodes)
addonEpisodes
} catch (e: Exception) {
if (e is CancellationException) throw e
deferred.completeExceptionally(e)
emptyList()
} finally {
cacheMutex.withLock { addonEpisodesInFlight.remove(cacheKey) }
}
}
private suspend fun fetchAddonEpisodes(
contentId: String,
contentType: String,
): List<EpisodeMappingEntry> {
val typeCandidates = buildList {
val normalized = contentType.lowercase()
if (normalized.isNotBlank()) add(normalized)
if (normalized in listOf("series", "tv")) {
add("series")
add("tv")
}
}.distinct()
if (typeCandidates.isEmpty()) return emptyList()
val idCandidates = buildList {
add(contentId)
if (contentId.startsWith("tmdb:")) add(contentId.substringAfter(':'))
if (contentId.startsWith("trakt:")) add(contentId.substringAfter(':'))
}.distinct()
for (type in typeCandidates) {
for (candidateId in idCandidates) {
val meta = withTimeoutOrNull(3_500L) {
MetaDetailsRepository.fetch(type = type, id = candidateId)
} ?: continue
val episodes = meta.videos.toEpisodeMappingEntries()
if (episodes.isNotEmpty()) return episodes
}
}
return emptyList()
}
// ── Trakt episodes fetching ─────────────────────────────────────────
private suspend fun getTraktEpisodes(showLookupId: String): List<EpisodeMappingEntry> {
cacheMutex.withLock {
traktEpisodesCache[showLookupId]?.let { return it }
}
val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList()
// Trakt API: GET /shows/{id}/seasons?extended=episodes
val url = "$BASE_URL/shows/$showLookupId/seasons?extended=episodes"
val payload = runCatching {
httpGetTextWithHeaders(url = url, headers = headers)
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "getTraktEpisodes: seasons request failed id=$showLookupId: ${e.message}" }
}.getOrNull() ?: return emptyList()
val traktEpisodes = parseTraktSeasonsPayload(payload)
if (traktEpisodes.isNotEmpty()) {
cacheMutex.withLock {
traktEpisodesCache[showLookupId] = traktEpisodes
}
}
return traktEpisodes
}
private fun parseTraktSeasonsPayload(payload: String): List<EpisodeMappingEntry> {
val seasons = runCatching {
json.decodeFromString<List<TraktSeasonDto>>(payload)
}.getOrNull() ?: return emptyList()
return seasons
.asSequence()
.filter { (it.number ?: 0) > 0 } // Skip specials (season 0)
.sortedBy { it.number }
.flatMap { seasonDto ->
seasonDto.episodes.orEmpty().asSequence().mapNotNull { episodeDto ->
val seasonNumber = episodeDto.season ?: seasonDto.number ?: return@mapNotNull null
val episodeNumber = episodeDto.number ?: return@mapNotNull null
EpisodeMappingEntry(
season = seasonNumber,
episode = episodeNumber,
title = episodeDto.title,
)
}
}
.toList()
}
// ── Helpers ───────────────────────────────────────────────────────────
private fun resolveShowLookupId(contentId: String?, videoId: String?): String? {
val contentIds = parseTraktContentIds(contentId)
if (contentIds.hasAnyId()) {
return when {
!contentIds.imdb.isNullOrBlank() -> contentIds.imdb
contentIds.trakt != null -> contentIds.trakt.toString()
contentIds.tmdb != null -> contentIds.tmdb.toString()
else -> null
}
}
val videoIds = parseTraktContentIds(videoId)
return when {
!videoIds.imdb.isNullOrBlank() -> videoIds.imdb
videoIds.trakt != null -> videoIds.trakt.toString()
videoIds.tmdb != null -> videoIds.tmdb.toString()
else -> null
}
}
private fun TraktExternalIds.hasAnyId(): Boolean =
!imdb.isNullOrBlank() || trakt != null || tmdb != null
private fun cacheKey(
contentId: String?,
contentType: String?,
videoId: String?,
season: Int?,
episode: Int?,
): String? {
val resolvedContentId = contentId?.trim()?.takeIf { it.isNotBlank() } ?: return null
val resolvedContentType = contentType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
val resolvedSeason = season ?: return null
val resolvedEpisode = episode ?: return null
val resolvedVideoId = videoId?.trim().orEmpty()
return "$resolvedContentType|$resolvedContentId|$resolvedVideoId|$resolvedSeason|$resolvedEpisode"
}
private fun reverseCacheKey(
contentId: String,
contentType: String,
season: Int,
episode: Int,
title: String?,
): String {
val normalizedTitle = title?.trim()?.lowercase().orEmpty()
return "reverse|${contentType.trim().lowercase()}|${contentId.trim()}|$season|$episode|$normalizedTitle"
}
private fun addonEpisodesCacheKey(contentId: String, contentType: String): String {
return "${contentType.trim().lowercase()}|${contentId.trim()}"
}
private fun List<MetaVideo>.toEpisodeMappingEntries(): List<EpisodeMappingEntry> {
return asSequence()
.mapNotNull { video ->
val season = video.season ?: return@mapNotNull null
val episode = video.episode ?: return@mapNotNull null
if (season <= 0) return@mapNotNull null
EpisodeMappingEntry(
season = season,
episode = episode,
title = video.title.takeIf { it.isNotBlank() },
videoId = video.id.takeIf { it.isNotBlank() },
)
}
.distinctBy { it.videoId ?: "${it.season}:${it.episode}" }
.sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
.toList()
}
private fun normalizeTitle(title: String?): String =
title.orEmpty().trim().lowercase()
.replace(Regex("[^a-z0-9]"), "")
}
// ── Data classes ────────────────────────────────────────────────────────
data class EpisodeMappingEntry(
val season: Int,
val episode: Int,
val title: String? = null,
val videoId: String? = null,
)
// ── Trakt API DTOs for seasons endpoint ─────────────────────────────────
@Serializable
private data class TraktSeasonDto(
@SerialName("number") val number: Int? = null,
@SerialName("episodes") val episodes: List<TraktSeasonEpisodeDto>? = null,
)
@Serializable
private data class TraktSeasonEpisodeDto(
@SerialName("number") val number: Int? = null,
@SerialName("season") val season: Int? = null,
@SerialName("title") val title: String? = null,
)

View file

@ -434,9 +434,31 @@ object TraktProgressRepository {
entries.map { entry -> entries.map { entry ->
val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry
val episode = if (entry.seasonNumber != null && entry.episodeNumber != null) { var resolvedSeason = entry.seasonNumber
meta.videos.firstOrNull { video -> var resolvedEpisode = entry.episodeNumber
video.season == entry.seasonNumber && video.episode == 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
} }
} else { } else {
null null
@ -447,6 +469,8 @@ object TraktProgressRepository {
logo = entry.logo ?: meta.logo, logo = entry.logo ?: meta.logo,
poster = entry.poster ?: meta.poster, poster = entry.poster ?: meta.poster,
background = entry.background ?: meta.background, background = entry.background ?: meta.background,
seasonNumber = resolvedSeason ?: entry.seasonNumber,
episodeNumber = resolvedEpisode ?: entry.episodeNumber,
episodeTitle = entry.episodeTitle ?: episode?.title, episodeTitle = entry.episodeTitle ?: episode?.title,
episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail,
pauseDescription = entry.pauseDescription pauseDescription = entry.pauseDescription

View file

@ -46,12 +46,30 @@ fun nextReleasedEpisodeAfter(
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }), compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
) )
val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber) val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber)
var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) == watchedVideoId
}
// Fallback: if the seed wasn't found by season+episode (anime with absolute
// numbering on Trakt vs multi-season on addon), try global index matching.
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber }
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
if (globalIndex in sortedEpisodes.indices) {
watchedIndex = globalIndex
}
}
}
if (watchedIndex < 0) return null
val watchedEpisodeSeason = sortedEpisodes[watchedIndex].seasonNumber
val candidates = sortedEpisodes val candidates = sortedEpisodes
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId } .drop(watchedIndex + 1)
.drop(1)
.filter { episode -> .filter { episode ->
shouldSurfaceNextEpisode( shouldSurfaceNextEpisode(
watchedSeasonNumber = seasonNumber, watchedSeasonNumber = watchedEpisodeSeason,
candidateSeasonNumber = episode.seasonNumber, candidateSeasonNumber = episode.seasonNumber,
todayIsoDate = todayIsoDate, todayIsoDate = todayIsoDate,
releasedDate = episode.releasedDate, releasedDate = episode.releasedDate,

View file

@ -166,7 +166,11 @@ fun latestCompletedSeriesEpisode(
{ it.markedAtEpochMs }, { it.markedAtEpochMs },
) )
} else { } else {
compareBy<WatchingCompletedEpisode> { it.markedAtEpochMs } compareBy<WatchingCompletedEpisode>(
{ it.markedAtEpochMs },
{ normalizeSeasonNumber(it.seasonNumber) },
{ it.episodeNumber },
)
} }
val allMarkers = buildList { val allMarkers = buildList {
progressRecords progressRecords

View file

@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedItem
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -92,7 +93,30 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
} }
} }
return result // Apply reverse mapping for anime: if Trakt uses absolute numbering (S1E1..S1EN)
// but addon uses multi-season, remap pulled episodes to addon numbering.
val remappedResult = mutableListOf<WatchedItem>()
for (item in result) {
if (item.season == null || item.episode == null || item.type != "series") {
remappedResult += item
continue
}
val mapped = runCatching {
TraktEpisodeMappingService.resolveAddonEpisodeMapping(
contentId = item.id,
contentType = item.type,
season = item.season,
episode = item.episode,
)
}.getOrNull()
if (mapped != null && (mapped.season != item.season || mapped.episode != item.episode)) {
remappedResult += item.copy(season = mapped.season, episode = mapped.episode)
} else {
remappedResult += item
}
}
return remappedResult
} }
// ── push (add to history) ─────────────────────────────────────────── // ── push (add to history) ───────────────────────────────────────────
@ -178,7 +202,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
), ),
) )
runCatching { val responseText = runCatching {
httpPostJsonWithHeaders( httpPostJsonWithHeaders(
url = "$BASE_URL/sync/history", url = "$BASE_URL/sync/history",
body = body, body = body,
@ -187,6 +211,101 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
}.onFailure { e -> }.onFailure { e ->
if (e is CancellationException) throw e if (e is CancellationException) throw e
log.w { "Failed to push watched items to Trakt: ${e.message}" } log.w { "Failed to push watched items to Trakt: ${e.message}" }
}.getOrNull()
// Retry with remapped numbering for episodes that Trakt didn't recognize
// (anime with different season structures between addon and Trakt).
if (responseText != null && shows.isNotEmpty()) {
val episodeItems = items.filter {
it.season != null && it.episode != null &&
it.type.trim().lowercase() !in listOf("movie", "film")
}
if (episodeItems.isNotEmpty()) {
retryWithRemappedEpisodes(headers, episodeItems)
}
}
}
private suspend fun retryWithRemappedEpisodes(
headers: Map<String, String>,
items: Collection<WatchedItem>,
) {
val remappedShows = mutableListOf<TraktHistoryShowRequestDto>()
for (item in items) {
val season = item.season ?: continue
val episode = item.episode ?: continue
val mapped = TraktEpisodeMappingService.resolveEpisodeMapping(
contentId = item.id,
contentType = item.type,
videoId = null,
season = season,
episode = episode,
) ?: continue
if (mapped.season == season && mapped.episode == episode) continue
val ids = parseIds(item.id) ?: continue
val existing = remappedShows.firstOrNull { it.ids == ids }
if (existing != null) {
val seasonDto = existing.seasons?.firstOrNull { it.number == mapped.season }
if (seasonDto != null) {
(seasonDto.episodes as? MutableList)?.add(
TraktHistoryEpisodeRequestDto(
number = mapped.episode,
watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
),
)
} else {
(existing.seasons as? MutableList)?.add(
TraktHistorySeasonRequestDto(
number = mapped.season,
episodes = mutableListOf(
TraktHistoryEpisodeRequestDto(
number = mapped.episode,
watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
),
),
),
)
}
} else {
remappedShows += TraktHistoryShowRequestDto(
title = item.name.takeIf { it.isNotBlank() },
year = parseYear(item.releaseInfo),
ids = ids,
seasons = mutableListOf(
TraktHistorySeasonRequestDto(
number = mapped.season,
episodes = mutableListOf(
TraktHistoryEpisodeRequestDto(
number = mapped.episode,
watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
),
),
),
),
)
}
}
if (remappedShows.isEmpty()) return
val retryBody = json.encodeToString(
TraktHistoryAddRequestDto(
movies = null,
shows = remappedShows,
),
)
runCatching {
httpPostJsonWithHeaders(
url = "$BASE_URL/sync/history",
body = retryBody,
headers = headers,
)
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "Failed to push remapped episodes to Trakt: ${e.message}" }
} }
} }
@ -251,6 +370,70 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
if (e is CancellationException) throw e if (e is CancellationException) throw e
log.w { "Failed to remove watched items from Trakt: ${e.message}" } log.w { "Failed to remove watched items from Trakt: ${e.message}" }
} }
// Retry removal with remapped numbering for anime cases
val episodeItems = items.filter {
it.season != null && it.episode != null &&
it.type.trim().lowercase() !in listOf("movie", "film")
}
if (episodeItems.isNotEmpty()) {
retryDeleteWithRemappedEpisodes(headers, episodeItems)
}
}
private suspend fun retryDeleteWithRemappedEpisodes(
headers: Map<String, String>,
items: Collection<WatchedItem>,
) {
val remappedShowDtos = mutableListOf<TraktHistoryShowRequestDto>()
for (item in items) {
val season = item.season ?: continue
val episode = item.episode ?: continue
val mapped = TraktEpisodeMappingService.resolveEpisodeMapping(
contentId = item.id,
contentType = item.type,
videoId = null,
season = season,
episode = episode,
) ?: continue
if (mapped.season == season && mapped.episode == episode) continue
val ids = parseIds(item.id) ?: continue
remappedShowDtos += TraktHistoryShowRequestDto(
title = item.name.takeIf { it.isNotBlank() },
year = parseYear(item.releaseInfo),
ids = ids,
seasons = listOf(
TraktHistorySeasonRequestDto(
number = mapped.season,
episodes = listOf(
TraktHistoryEpisodeRequestDto(number = mapped.episode),
),
),
),
)
}
if (remappedShowDtos.isEmpty()) return
val retryBody = json.encodeToString(
TraktHistoryRemoveRequestDto(
movies = null,
shows = remappedShowDtos,
),
)
runCatching {
httpPostJsonWithHeaders(
url = "$BASE_URL/sync/history/remove",
body = retryBody,
headers = headers,
)
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "Failed to remove remapped episodes from Trakt: ${e.message}" }
}
} }
// ── helpers ───────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────