diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index 3c3374fa..bf4b6744 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -85,19 +85,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter( seasonNumber = seasonNumber, episodeNumber = episodeNumber, ) - val candidates = sortedEpisodes - .dropWhile { episode -> - buildPlaybackVideoId( - content = WatchingContentRef(type = type, id = id), - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = episode.id, - ) != watchedVideoId + var watchedIndex = sortedEpisodes.indexOfFirst { episode -> + buildPlaybackVideoId( + content = WatchingContentRef(type = type, id = id), + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) == 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.season } + if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) { + val globalIndex = episodeNumber - 1 + if (globalIndex in sortedEpisodes.indices) { + watchedIndex = globalIndex + } } - .drop(1) + } + + if (watchedIndex < 0) return null + + val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season + val candidates = sortedEpisodes + .drop(watchedIndex + 1) .filter { episode -> shouldSurfaceNextEpisode( - watchedSeasonNumber = seasonNumber, + watchedSeasonNumber = watchedEpisodeSeason, candidateSeasonNumber = episode.season, todayIsoDate = todayIsoDate, releasedDate = episode.released, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt new file mode 100644 index 00000000..aeef2ce9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt @@ -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 S1E1–S1E25, S2E1–S2E12, etc. + * Trakt may list it as S1E1–S1E87 (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() + private val reverseMappingCache = mutableMapOf() + private val addonEpisodesCache = mutableMapOf>() + private val traktEpisodesCache = mutableMapOf>() + // In-flight dedup: prevents multiple concurrent coroutines from fetching + // the same show's addon episodes simultaneously. + private val addonEpisodesInFlight = mutableMapOf>>() + + // ── 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, + traktEpisodes: List, + ): 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, + traktEpisodes: List, + ): 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, + traktEpisodes: List, + ): 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 { + 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>() + 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 { + 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 { + 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 { + val seasons = runCatching { + json.decodeFromString>(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.toEpisodeMappingEntries(): List { + 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? = null, +) + +@Serializable +private data class TraktSeasonEpisodeDto( + @SerialName("number") val number: Int? = null, + @SerialName("season") val season: Int? = null, + @SerialName("title") val title: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index 6d10a78c..6ca02f0b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -434,9 +434,31 @@ object TraktProgressRepository { entries.map { entry -> val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry - val episode = if (entry.seasonNumber != null && entry.episodeNumber != null) { - meta.videos.firstOrNull { video -> - video.season == entry.seasonNumber && video.episode == entry.episodeNumber + var resolvedSeason = entry.seasonNumber + 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 } } else { null @@ -447,6 +469,8 @@ object TraktProgressRepository { logo = entry.logo ?: meta.logo, poster = entry.poster ?: meta.poster, background = entry.background ?: meta.background, + seasonNumber = resolvedSeason ?: entry.seasonNumber, + episodeNumber = resolvedEpisode ?: entry.episodeNumber, episodeTitle = entry.episodeTitle ?: episode?.title, episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, pauseDescription = entry.pauseDescription diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index 359eec29..59c074ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -46,12 +46,30 @@ fun nextReleasedEpisodeAfter( compareBy({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }), ) 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 - .dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId } - .drop(1) + .drop(watchedIndex + 1) .filter { episode -> shouldSurfaceNextEpisode( - watchedSeasonNumber = seasonNumber, + watchedSeasonNumber = watchedEpisodeSeason, candidateSeasonNumber = episode.seasonNumber, todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt index 237f9dcf..27c6fcd1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt @@ -166,7 +166,11 @@ fun latestCompletedSeriesEpisode( { it.markedAtEpochMs }, ) } else { - compareBy { it.markedAtEpochMs } + compareBy( + { it.markedAtEpochMs }, + { normalizeSeasonNumber(it.seasonNumber) }, + { it.episodeNumber }, + ) } val allMarkers = buildList { progressRecords diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index 714dbcf7..162daa99 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.watched.WatchedItem import kotlinx.coroutines.CancellationException 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() + 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) ─────────────────────────────────────────── @@ -178,7 +202,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ), ) - runCatching { + val responseText = runCatching { httpPostJsonWithHeaders( url = "$BASE_URL/sync/history", body = body, @@ -187,6 +211,101 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { }.onFailure { e -> if (e is CancellationException) throw e 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, + items: Collection, + ) { + val remappedShows = mutableListOf() + + 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 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, + items: Collection, + ) { + val remappedShowDtos = mutableListOf() + + 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 ─────────────────────────────────────────────────────────