diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt index 7ae9314d8..fab694939 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt @@ -95,7 +95,13 @@ data class Episode( val imdbRating: String = "", val runtime: Int = 0, val airDate: String = "", - val isWatched: Boolean = false + val isWatched: Boolean = false, + /** Canonical TMDB coordinates used by Trakt, history, ratings and metadata lookups. */ + val tmdbSeasonNumber: Int = seasonNumber, + val tmdbEpisodeNumber: Int = episodeNumber, + /** Provider identity used when the anime-facing structure differs from TMDB. */ + val kitsuId: Int? = null, + val kitsuEpisodeNumber: Int? = null ) : Serializable /** diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 3834878b4..d93dc73b1 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -1856,6 +1856,7 @@ class StreamRepository @Inject constructor( genreIds: List = emptyList(), originalLanguage: String? = null, title: String = "", + animeQueryOverride: String? = null, airDate: String? = null ): List { val startedAt = System.currentTimeMillis() @@ -1914,7 +1915,7 @@ class StreamRepository @Inject constructor( ANIME_ID_LOOKUP_TIMEOUT_MS } val animeQuery = if (resolveAsAnime) { - resolveAnimeQuery(animeLookupTimeoutMs) + animeQueryOverride ?: resolveAnimeQuery(animeLookupTimeoutMs) } else null val seriesId = "$imdbId:$season:$episode" @@ -2661,6 +2662,7 @@ class StreamRepository @Inject constructor( originalLanguage: String? = null, title: String = "", forceRefresh: Boolean = false, + animeQueryOverride: String? = null, airDate: String? = null ): Flow = callbackFlow { repositoryScope.launch { @@ -2795,6 +2797,7 @@ class StreamRepository @Inject constructor( genreIds = genreIds, originalLanguage = originalLanguage, title = title, + animeQueryOverride = animeQueryOverride, airDate = airDate ) } catch (e: Exception) { diff --git a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt index 0556a1342..5c2c68480 100644 --- a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt @@ -81,12 +81,14 @@ sealed class Screen(val route: String) { } } - data object Player : Screen("player/{mediaType}/{mediaId}?seasonNumber={seasonNumber}&episodeNumber={episodeNumber}&imdbId={imdbId}&streamUrl={streamUrl}&preferredAddonId={preferredAddonId}&preferredSourceName={preferredSourceName}&preferredBingeGroup={preferredBingeGroup}&startPositionMs={startPositionMs}&isLiveStream={isLiveStream}") { + data object Player : Screen("player/{mediaType}/{mediaId}?seasonNumber={seasonNumber}&episodeNumber={episodeNumber}&tmdbSeasonNumber={tmdbSeasonNumber}&tmdbEpisodeNumber={tmdbEpisodeNumber}&imdbId={imdbId}&streamUrl={streamUrl}&preferredAddonId={preferredAddonId}&preferredSourceName={preferredSourceName}&preferredBingeGroup={preferredBingeGroup}&startPositionMs={startPositionMs}&isLiveStream={isLiveStream}") { fun createRoute( mediaType: MediaType, mediaId: Int, seasonNumber: Int? = null, episodeNumber: Int? = null, + tmdbSeasonNumber: Int? = seasonNumber, + tmdbEpisodeNumber: Int? = episodeNumber, imdbId: String? = null, streamUrl: String? = null, preferredAddonId: String? = null, @@ -99,6 +101,8 @@ sealed class Screen(val route: String) { val params = mutableListOf() seasonNumber?.let { params.add("seasonNumber=$it") } episodeNumber?.let { params.add("episodeNumber=$it") } + tmdbSeasonNumber?.let { params.add("tmdbSeasonNumber=$it") } + tmdbEpisodeNumber?.let { params.add("tmdbEpisodeNumber=$it") } imdbId?.let { params.add("imdbId=${java.net.URLEncoder.encode(it, "UTF-8")}") } streamUrl?.let { params.add("streamUrl=${java.net.URLEncoder.encode(it, "UTF-8")}") } preferredAddonId?.let { params.add("preferredAddonId=${java.net.URLEncoder.encode(it, "UTF-8")}") } @@ -424,13 +428,15 @@ fun AppNavigation( initialSeason = initialSeason, initialEpisode = initialEpisode, currentProfile = currentProfile, - onNavigateToPlayer = { type, id, season, episode, imdbId, url, preferredAddonId, preferredSourceName, startPositionMs -> + onNavigateToPlayer = { type, id, season, episode, tmdbSeason, tmdbEpisode, imdbId, url, preferredAddonId, preferredSourceName, startPositionMs -> navController.navigate( Screen.Player.createRoute( mediaType = type, mediaId = id, seasonNumber = season, episodeNumber = episode, + tmdbSeasonNumber = tmdbSeason, + tmdbEpisodeNumber = tmdbEpisode, imdbId = imdbId, streamUrl = url, preferredAddonId = preferredAddonId, @@ -484,6 +490,14 @@ fun AppNavigation( type = NavType.IntType defaultValue = -1 }, + navArgument("tmdbSeasonNumber") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("tmdbEpisodeNumber") { + type = NavType.IntType + defaultValue = -1 + }, navArgument("imdbId") { type = NavType.StringType defaultValue = "" @@ -518,6 +532,10 @@ fun AppNavigation( val mediaId = backStackEntry.arguments?.getInt("mediaId") ?: 0 val seasonNumber = backStackEntry.arguments?.getInt("seasonNumber")?.takeIf { it >= 0 } val episodeNumber = backStackEntry.arguments?.getInt("episodeNumber")?.takeIf { it >= 0 } + val tmdbSeasonNumber = backStackEntry.arguments?.getInt("tmdbSeasonNumber")?.takeIf { it >= 0 } + ?: seasonNumber + val tmdbEpisodeNumber = backStackEntry.arguments?.getInt("tmdbEpisodeNumber")?.takeIf { it >= 0 } + ?: episodeNumber val imdbId = backStackEntry.arguments?.getString("imdbId")?.takeIf { it.isNotBlank() } val streamUrl = backStackEntry.arguments?.getString("streamUrl")?.takeIf { it.isNotEmpty() } val preferredAddonId = backStackEntry.arguments?.getString("preferredAddonId")?.takeIf { it.isNotBlank() } @@ -532,6 +550,8 @@ fun AppNavigation( mediaId = mediaId, seasonNumber = seasonNumber, episodeNumber = episodeNumber, + tmdbSeasonNumber = tmdbSeasonNumber, + tmdbEpisodeNumber = tmdbEpisodeNumber, imdbId = imdbId, streamUrl = streamUrl, preferredAddonId = preferredAddonId, @@ -540,7 +560,7 @@ fun AppNavigation( startPositionMs = startPositionMs, isLiveStream = isLiveStream, onBack = { navController.popBackStack() }, - onPlayNext = { nextSeason, nextEpisode, nextPreferredAddonId, nextPreferredSourceName, nextPreferredBingeGroup -> + onPlayNext = { nextSeason, nextEpisode, nextTmdbSeason, nextTmdbEpisode, nextPreferredAddonId, nextPreferredSourceName, nextPreferredBingeGroup -> // Navigate to next episode navController.navigate( Screen.Player.createRoute( @@ -548,6 +568,8 @@ fun AppNavigation( mediaId = mediaId, seasonNumber = nextSeason, episodeNumber = nextEpisode, + tmdbSeasonNumber = nextTmdbSeason, + tmdbEpisodeNumber = nextTmdbEpisode, preferredAddonId = nextPreferredAddonId, preferredSourceName = nextPreferredSourceName, preferredBingeGroup = nextPreferredBingeGroup diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index a4e5effb0..426859e0a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -203,7 +203,7 @@ fun DetailsScreen( initialEpisode: Int? = null, viewModel: DetailsViewModel = hiltViewModel(), currentProfile: com.arflix.tv.data.model.Profile? = null, - onNavigateToPlayer: (MediaType, Int, Int?, Int?, String?, String?, String?, String?, Long?) -> Unit, + onNavigateToPlayer: (MediaType, Int, Int?, Int?, Int?, Int?, String?, String?, String?, String?, Long?) -> Unit, onNavigateToDetails: (MediaType, Int) -> Unit, onNavigateToCollection: (String) -> Unit = {}, onNavigateToHome: () -> Unit = {}, @@ -254,13 +254,22 @@ fun DetailsScreen( var seasonSelectDownAtMs by remember { mutableLongStateOf(0L) } var ignoreFirstResumeRefresh by remember(mediaType, mediaId, initialSeason, initialEpisode) { mutableStateOf(true) } - fun requestFastAutoPlay(imdbId: String?, season: Int?, episode: Int?, startPositionMs: Long?) { + fun requestFastAutoPlay( + imdbId: String?, + displaySeason: Int?, + displayEpisode: Int?, + startPositionMs: Long?, + tmdbSeason: Int? = displaySeason, + tmdbEpisode: Int? = displayEpisode + ) { showStreamSelector = false - viewModel.loadStreams(imdbId, season, episode) + viewModel.loadStreams(imdbId, tmdbSeason, tmdbEpisode) autoPlayWaitTick = 0 pendingAutoPlayRequest = PendingAutoPlayRequest( - season = season, - episode = episode, + displaySeason = displaySeason, + displayEpisode = displayEpisode, + tmdbSeason = tmdbSeason, + tmdbEpisode = tmdbEpisode, startPositionMs = startPositionMs, requestedAtMs = SystemClock.elapsedRealtime() ) @@ -353,12 +362,14 @@ fun DetailsScreen( when { selectedStream != null && !shouldWaitForSources -> { - viewModel.recordPlayedEpisode(mediaId, request.season, request.episode) + viewModel.recordPlayedEpisode(mediaId, request.displaySeason, request.displayEpisode) onNavigateToPlayer( mediaType, mediaId, - request.season, - request.episode, + request.displaySeason, + request.displayEpisode, + request.tmdbSeason, + request.tmdbEpisode, uiState.imdbId, selectedStream.url?.takeIf { it.isNotBlank() }, selectedStream.addonId.takeIf { it.isNotBlank() }, @@ -457,16 +468,24 @@ fun DetailsScreen( if (!state.autoPlaySingleSource) { // Autoplay OFF → open the source picker; never auto-play. showStreamSelector = true - viewModel.loadStreams(state.imdbId, season, episode) + viewModel.loadStreams( + state.imdbId, + state.playTmdbSeason ?: season, + state.playTmdbEpisode ?: episode + ) } else { // Autoplay ON → go straight to the player; PlayerScreen auto-picks. - requestFastAutoPlay(state.imdbId, season, episode, startPositionMs) + requestFastAutoPlay( + state.imdbId, season, episode, startPositionMs, + state.playTmdbSeason ?: season, + state.playTmdbEpisode ?: episode + ) } } 1 -> { // Sources showStreamSelector = true val ep = state.episodes.getOrNull(currentEpIdx) - viewModel.loadStreams(state.imdbId, ep?.seasonNumber, ep?.episodeNumber) + viewModel.loadStreams(state.imdbId, ep?.tmdbSeasonNumber, ep?.tmdbEpisodeNumber) } 2 -> { // Trailer state.trailerKey?.let { showTrailerPlayer = true } @@ -505,9 +524,12 @@ fun DetailsScreen( episodeIndex = idx if (isMobile || !state.autoPlaySingleSource) { showStreamSelector = true - viewModel.loadStreams(state.imdbId, ep.seasonNumber, ep.episodeNumber) + viewModel.loadStreams(state.imdbId, ep.tmdbSeasonNumber, ep.tmdbEpisodeNumber) } else { - requestFastAutoPlay(state.imdbId, ep.seasonNumber, ep.episodeNumber, null) + requestFastAutoPlay( + state.imdbId, ep.seasonNumber, ep.episodeNumber, null, + ep.tmdbSeasonNumber, ep.tmdbEpisodeNumber + ) } } } @@ -814,17 +836,25 @@ fun DetailsScreen( if (!uiState.autoPlaySingleSource) { // Autoplay OFF → open the source picker; never auto-play. showStreamSelector = true - viewModel.loadStreams(uiState.imdbId, season, episode) + viewModel.loadStreams( + uiState.imdbId, + uiState.playTmdbSeason ?: season, + uiState.playTmdbEpisode ?: episode + ) } else { // Autoplay ON: pick a concrete stream first, then open PlayerScreen. - requestFastAutoPlay(uiState.imdbId, season, episode, startPositionMs) + requestFastAutoPlay( + uiState.imdbId, season, episode, startPositionMs, + uiState.playTmdbSeason ?: season, + uiState.playTmdbEpisode ?: episode + ) } } 1 -> { // Sources - Show StreamSelector for manual selection showStreamSelector = true // Pass the currently focused episode for TV shows val ep = uiState.episodes.getOrNull(episodeIndex) - viewModel.loadStreams(uiState.imdbId, ep?.seasonNumber, ep?.episodeNumber) + viewModel.loadStreams(uiState.imdbId, ep?.tmdbSeasonNumber, ep?.tmdbEpisodeNumber) } 2 -> { // Trailer uiState.trailerKey?.let { @@ -844,9 +874,12 @@ fun DetailsScreen( if (ep != null) { if (!uiState.autoPlaySingleSource) { showStreamSelector = true - viewModel.loadStreams(uiState.imdbId, ep.seasonNumber, ep.episodeNumber) + viewModel.loadStreams(uiState.imdbId, ep.tmdbSeasonNumber, ep.tmdbEpisodeNumber) } else { - requestFastAutoPlay(uiState.imdbId, ep.seasonNumber, ep.episodeNumber, null) + requestFastAutoPlay( + uiState.imdbId, ep.seasonNumber, ep.episodeNumber, null, + ep.tmdbSeasonNumber, ep.tmdbEpisodeNumber + ) } } } @@ -1064,6 +1097,7 @@ fun DetailsScreen( onNavigateToPlayer( mediaType, mediaId, ep?.seasonNumber, ep?.episodeNumber, + ep?.tmdbSeasonNumber, ep?.tmdbEpisodeNumber, uiState.imdbId, stream.url?.takeIf { it.isNotBlank() }, stream.addonId.takeIf { it.isNotBlank() }, @@ -1083,12 +1117,15 @@ fun DetailsScreen( isWatched = episode.isWatched, onPlay = { showEpisodeContextMenu = false - requestFastAutoPlay(uiState.imdbId, episode.seasonNumber, episode.episodeNumber, null) + requestFastAutoPlay( + uiState.imdbId, episode.seasonNumber, episode.episodeNumber, null, + episode.tmdbSeasonNumber, episode.tmdbEpisodeNumber + ) }, onSelectSource = { showEpisodeContextMenu = false showStreamSelector = true - viewModel.loadStreams(uiState.imdbId, episode.seasonNumber, episode.episodeNumber) + viewModel.loadStreams(uiState.imdbId, episode.tmdbSeasonNumber, episode.tmdbEpisodeNumber) }, onToggleWatched = { viewModel.markEpisodeWatched( @@ -1140,8 +1177,10 @@ private enum class FocusSection { } private data class PendingAutoPlayRequest( - val season: Int?, - val episode: Int?, + val displaySeason: Int?, + val displayEpisode: Int?, + val tmdbSeason: Int?, + val tmdbEpisode: Int?, val startPositionMs: Long?, val requestedAtMs: Long ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index b38c6fb74..cacda9a38 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -29,6 +29,8 @@ import com.arflix.tv.data.repository.TraktRepository import com.arflix.tv.data.repository.WatchHistoryRepository import com.arflix.tv.data.repository.WatchlistRepository import com.arflix.tv.util.AppLogger +import com.arflix.tv.util.AnimeMapper +import com.arflix.tv.util.AnimeSeasonStructure import com.arflix.tv.util.Constants import com.arflix.tv.util.settingsDataStore import dagger.hilt.android.lifecycle.HiltViewModel @@ -99,6 +101,8 @@ data class DetailsUiState( val seasonProgress: Map> = emptyMap(), val playSeason: Int? = null, val playEpisode: Int? = null, + val playTmdbSeason: Int? = null, + val playTmdbEpisode: Int? = null, val playLabel: String? = null, val playPositionMs: Long? = null, val autoPlaySingleSource: Boolean = true, @@ -193,6 +197,7 @@ class DetailsViewModel @Inject constructor( private val traktRepository: TraktRepository, private val remoteSyncManager: com.arflix.tv.data.repository.sync.RemoteSyncManager, private val streamRepository: StreamRepository, + private val animeMapper: AnimeMapper, private val tmdbApi: TmdbApi, private val watchHistoryRepository: WatchHistoryRepository, private val watchlistRepository: WatchlistRepository, @@ -213,6 +218,7 @@ class DetailsViewModel @Inject constructor( private var currentMediaType: MediaType = MediaType.MOVIE private var currentMediaId: Int = 0 + private var animeSeasonStructure: AnimeSeasonStructure? = null // The episode the user last started playing from this screen, kept so that returning from the // player (which recreates the details screen) points the Continue button at that episode even // when the play was too brief to record a resume point. Scoped to a media id. @@ -285,6 +291,7 @@ class DetailsViewModel @Inject constructor( fun loadDetails(mediaType: MediaType, mediaId: Int, initialSeason: Int? = null, initialEpisode: Int? = null) { currentMediaType = mediaType currentMediaId = mediaId + animeSeasonStructure = null initialLoadComplete = false vodAppendJob?.cancel() homeServerAppendJob?.cancel() @@ -354,6 +361,8 @@ class DetailsViewModel @Inject constructor( totalSeasons = cachedTotalSeasons, playSeason = initialSeason, playEpisode = initialEpisode, + playTmdbSeason = initialSeason, + playTmdbEpisode = initialEpisode, playLabel = if (mediaType == MediaType.TV && initialSeason != null && initialEpisode != null) { context.getString(R.string.continue_season_episode, initialSeason, initialEpisode) } else { @@ -509,6 +518,56 @@ class DetailsViewModel @Inject constructor( ) _uiState.value = baseState + // ARM/Kitsu may expose several anime seasons for a single TMDB season. Resolve this + // after the fast TMDB details path and only replace the UI when the mapping is + // complete; otherwise the existing TMDB structure remains untouched. + if ( + mediaType == MediaType.TV && + animeMapper.isAnimeContent(mediaId, mergedItem.genreIds, mergedItem.originalLanguage) + ) { + launch { + val structure = animeMapper.resolveAnimeSeasonStructure(mediaId) + ?: return@launch + if (!isCurrentRequest()) return@launch + val currentState = _uiState.value + val canonicalTargetSeason = currentState.playTmdbSeason + ?: currentState.playSeason + ?: seasonToLoad + val canonicalTargetEpisode = currentState.playTmdbEpisode + ?: currentState.playEpisode + ?: initialEpisode + ?: 1 + val displayTarget = structure.identityForTmdb( + canonicalTargetSeason, + canonicalTargetEpisode + ) + val requestedDisplaySeason = displayTarget?.displaySeason + ?: seasonToLoad.coerceIn(1, structure.seasonCount) + val animeEpisodes = loadAnimeDisplaySeason(mediaId, requestedDisplaySeason, structure) + if (animeEpisodes.isEmpty() || !isCurrentRequest()) return@launch + animeSeasonStructure = structure + updateState { state -> + state.copy( + totalSeasons = structure.seasonCount, + currentSeason = requestedDisplaySeason, + episodes = animeEpisodes, + initialSeasonIndex = requestedDisplaySeason - 1, + playSeason = displayTarget?.displaySeason ?: state.playSeason, + playEpisode = displayTarget?.displayEpisode ?: state.playEpisode, + playTmdbSeason = canonicalTargetSeason, + playTmdbEpisode = canonicalTargetEpisode, + playLabel = displayTarget?.let { + context.getString( + R.string.continue_season_episode, + it.displaySeason, + it.displayEpisode + ) + } ?: state.playLabel + ) + } + } + } + // Calculate initial season index (0-based) val initialSeasonIndex = (seasonToLoad - 1).coerceAtLeast(0) updateState { it.copy(initialSeasonIndex = initialSeasonIndex) } @@ -783,10 +842,18 @@ class DetailsViewModel @Inject constructor( it.season == initialSeason && it.episode == initialEpisode } updateState { state -> + val displayTarget = animeSeasonStructure?.identityForTmdb( + initialSeason, + initialEpisode + ) state.copy( - playSeason = initialSeason, - playEpisode = initialEpisode, - playLabel = matchedResume?.label ?: context.getString(R.string.continue_season_episode, initialSeason, initialEpisode), + playSeason = displayTarget?.displaySeason ?: initialSeason, + playEpisode = displayTarget?.displayEpisode ?: initialEpisode, + playTmdbSeason = initialSeason, + playTmdbEpisode = initialEpisode, + playLabel = displayTarget?.let { + context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + } ?: matchedResume?.label ?: context.getString(R.string.continue_season_episode, initialSeason, initialEpisode), playPositionMs = matchedResume?.positionMs ) } @@ -796,10 +863,19 @@ class DetailsViewModel @Inject constructor( // Fast path: show Continue immediately from local history. val playTarget = buildPlayTarget(mediaType, null, resumeInfo) updateState { state -> + val displayTarget = playTarget?.let { target -> + val targetSeason = target.season ?: return@let null + val targetEpisode = target.episode ?: return@let null + animeSeasonStructure?.identityForTmdb(targetSeason, targetEpisode) + } state.copy( - playSeason = playTarget?.season, - playEpisode = playTarget?.episode, - playLabel = playTarget?.label, + playSeason = displayTarget?.displaySeason ?: playTarget?.season, + playEpisode = displayTarget?.displayEpisode ?: playTarget?.episode, + playTmdbSeason = playTarget?.season, + playTmdbEpisode = playTarget?.episode, + playLabel = displayTarget?.let { + context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + } ?: playTarget?.label, playPositionMs = playTarget?.positionMs ) } @@ -807,10 +883,19 @@ class DetailsViewModel @Inject constructor( val seasonProgressResult = runCatching { seasonProgressDeferred?.await() }.getOrNull() val playTarget = buildPlayTarget(mediaType, seasonProgressResult, null) updateState { state -> + val displayTarget = playTarget?.let { target -> + val targetSeason = target.season ?: return@let null + val targetEpisode = target.episode ?: return@let null + animeSeasonStructure?.identityForTmdb(targetSeason, targetEpisode) + } state.copy( - playSeason = playTarget?.season, - playEpisode = playTarget?.episode, - playLabel = playTarget?.label, + playSeason = displayTarget?.displaySeason ?: playTarget?.season, + playEpisode = displayTarget?.displayEpisode ?: playTarget?.episode, + playTmdbSeason = playTarget?.season, + playTmdbEpisode = playTarget?.episode, + playLabel = displayTarget?.let { + context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + } ?: playTarget?.label, playPositionMs = playTarget?.positionMs ) } @@ -887,7 +972,30 @@ class DetailsViewModel @Inject constructor( val currentEpisodes = _uiState.value.episodes try { - val episodes = mediaRepository.getSeasonEpisodes(currentMediaId, seasonNumber) + val structure = animeSeasonStructure + val episodes = if (structure != null) { + loadAnimeDisplaySeason(currentMediaId, seasonNumber, structure) + } else { + val canonicalEpisodes = mediaRepository.getSeasonEpisodes(currentMediaId, seasonNumber) + val item = _uiState.value.item + val isAnime = animeMapper.isAnimeContent( + currentMediaId, + item?.genreIds.orEmpty(), + item?.originalLanguage + ) + if (isAnime && canonicalEpisodes.isNotEmpty() && canonicalEpisodes.first().episodeNumber != 1) { + canonicalEpisodes.mapIndexed { index, episode -> + episode.copy( + episodeNumber = index + 1, + seasonNumber = seasonNumber, + tmdbSeasonNumber = episode.seasonNumber, + tmdbEpisodeNumber = episode.episodeNumber + ) + } + } else { + canonicalEpisodes + } + } // A newer request superseded this one while we were fetching — drop the stale result. if (seasonLoadRequestedSeason != seasonNumber) return@launch if (episodes.isNotEmpty()) { @@ -897,7 +1005,7 @@ class DetailsViewModel @Inject constructor( }.getOrDefault(emptySet()) val decoratedEpisodes = if (watchedKeys.isNotEmpty()) { episodes.map { ep -> - val key = "show_tmdb:$currentMediaId:${ep.seasonNumber}:${ep.episodeNumber}" + val key = "show_tmdb:$currentMediaId:${ep.tmdbSeasonNumber}:${ep.tmdbEpisodeNumber}" if (watchedKeys.contains(key)) ep.copy(isWatched = true) else ep } } else { @@ -937,6 +1045,32 @@ class DetailsViewModel @Inject constructor( } } + private suspend fun loadAnimeDisplaySeason( + tmdbId: Int, + displaySeason: Int, + structure: AnimeSeasonStructure + ): List { + val identities = structure.seasons[displaySeason] ?: return emptyList() + val canonicalBySeason = identities + .map { it.tmdbSeason } + .distinct() + .associateWith { season -> + mediaRepository.getSeasonEpisodes(tmdbId, season).associateBy { it.episodeNumber } + } + return identities.mapNotNull { identity -> + canonicalBySeason[identity.tmdbSeason] + ?.get(identity.tmdbEpisode) + ?.copy( + seasonNumber = identity.displaySeason, + episodeNumber = identity.displayEpisode, + tmdbSeasonNumber = identity.tmdbSeason, + tmdbEpisodeNumber = identity.tmdbEpisode, + kitsuId = identity.kitsuId, + kitsuEpisodeNumber = identity.displayEpisode + ) + } + } + fun toggleWatched(episodeIndex: Int? = null) { val currentItem = _uiState.value.item ?: return @@ -970,25 +1104,30 @@ class DetailsViewModel @Inject constructor( if (episodeWatched) { traktRepository.markEpisodeWatched( currentMediaId, - targetEpisode.seasonNumber, - targetEpisode.episodeNumber + targetEpisode.tmdbSeasonNumber, + targetEpisode.tmdbEpisodeNumber ) watchHistoryRepository.removeFromHistory( currentMediaId, - targetEpisode.seasonNumber, - targetEpisode.episodeNumber + targetEpisode.tmdbSeasonNumber, + targetEpisode.tmdbEpisodeNumber ) // Save the NEXT episode to CW (local + cloud) so it appears on all devices try { - val nextEp = targetEpisode.episodeNumber + 1 + val nextIdentity = animeSeasonStructure?.nextAfterDisplay( + targetEpisode.seasonNumber, + targetEpisode.episodeNumber + ) + val nextSeason = nextIdentity?.tmdbSeason ?: targetEpisode.tmdbSeasonNumber + val nextEp = nextIdentity?.tmdbEpisode ?: targetEpisode.tmdbEpisodeNumber + 1 traktRepository.saveLocalContinueWatching( mediaType = MediaType.TV, tmdbId = currentMediaId, title = currentItem.title, posterPath = currentItem.image, backdropPath = currentItem.backdrop, - season = targetEpisode.seasonNumber, + season = nextSeason, episode = nextEp, episodeTitle = null, progress = 3, @@ -1002,7 +1141,7 @@ class DetailsViewModel @Inject constructor( title = currentItem.title, poster = currentItem.image, backdrop = currentItem.backdrop, - season = targetEpisode.seasonNumber, + season = nextSeason, episode = nextEp, episodeTitle = null, progress = 0.01f, @@ -1016,8 +1155,8 @@ class DetailsViewModel @Inject constructor( } else { traktRepository.markEpisodeUnwatched( currentMediaId, - targetEpisode.seasonNumber, - targetEpisode.episodeNumber + targetEpisode.tmdbSeasonNumber, + targetEpisode.tmdbEpisodeNumber ) } @@ -1174,7 +1313,7 @@ class DetailsViewModel @Inject constructor( val prefix = "show_tmdb:$tmdbId:" if (watchedKeys.any { it.startsWith(prefix) }) { updatedEpisodes = latestForEpisodes.episodes.map { ep -> - val key = "show_tmdb:$tmdbId:${ep.seasonNumber}:${ep.episodeNumber}" + val key = "show_tmdb:$tmdbId:${ep.tmdbSeasonNumber}:${ep.tmdbEpisodeNumber}" ep.copy(isWatched = ep.isWatched || watchedKeys.contains(key)) } val season = latestForEpisodes.currentSeason @@ -1202,6 +1341,11 @@ class DetailsViewModel @Inject constructor( // Read latest state to avoid overwriting concurrent updates (e.g. seasonProgress) val latestState = _uiState.value + val displayPlayTarget = playTarget?.let { target -> + val season = target.season ?: return@let null + val episode = target.episode ?: return@let null + animeSeasonStructure?.identityForTmdb(season, episode) + } _uiState.value = latestState.copy( item = updatedItem ?: latestState.item, // Only overwrite episodes if we actually computed watched badges; @@ -1209,9 +1353,13 @@ class DetailsViewModel @Inject constructor( episodes = if (updatedEpisodes.isNotEmpty()) updatedEpisodes else latestState.episodes, // Only update seasonProgress if we actually computed new data; preserve existing otherwise seasonProgress = if (updatedProgress !== latestForEpisodes.seasonProgress) updatedProgress else latestState.seasonProgress, - playSeason = playTarget?.season ?: latestState.playSeason, - playEpisode = playTarget?.episode ?: latestState.playEpisode, - playLabel = playTarget?.label ?: latestState.playLabel, + playSeason = displayPlayTarget?.displaySeason ?: playTarget?.season ?: latestState.playSeason, + playEpisode = displayPlayTarget?.displayEpisode ?: playTarget?.episode ?: latestState.playEpisode, + playTmdbSeason = playTarget?.season ?: latestState.playTmdbSeason, + playTmdbEpisode = playTarget?.episode ?: latestState.playTmdbEpisode, + playLabel = displayPlayTarget?.let { + context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + } ?: playTarget?.label ?: latestState.playLabel, playPositionMs = playTarget?.positionMs ?: 0L ) } @@ -1479,13 +1627,25 @@ class DetailsViewModel @Inject constructor( val item = _uiState.value.item val genreIds = item?.genreIds ?: emptyList() val originalLanguage = item?.originalLanguage + val selectedEpisode = if (requestMediaType == MediaType.TV) { + _uiState.value.episodes.firstOrNull { + it.seasonNumber == (season ?: 1) && it.episodeNumber == (episode ?: 1) + } + } else null + val canonicalSeason = selectedEpisode?.tmdbSeasonNumber ?: season + val canonicalEpisode = selectedEpisode?.tmdbEpisodeNumber ?: episode + val animeQueryOverride = selectedEpisode?.kitsuId?.let { kitsuId -> + selectedEpisode.kitsuEpisodeNumber?.let { kitsuEpisode -> + "kitsu:$kitsuId:$kitsuEpisode" + } + } val hasHomeServerConnections = streamRepository.hasHomeServerConnections() // Start VOD append in background - runs parallel to addon stream fetch homeServerAppendJob = viewModelScope.launch { appendHomeServerSourcesInBackground( imdbId = resolvedImdbId, - season = season, - episode = episode, + season = canonicalSeason, + episode = canonicalEpisode, timeoutMs = 5_000L, requestId = requestId, requestMediaType = requestMediaType, @@ -1499,8 +1659,8 @@ class DetailsViewModel @Inject constructor( val vodTimeout = if (currentMediaType == MediaType.MOVIE) 30_000L else 45_000L appendVodSourceInBackground( imdbId = resolvedImdbId, - season = season, - episode = episode, + season = canonicalSeason, + episode = canonicalEpisode, timeoutMs = vodTimeout, requestId = requestId, requestMediaType = requestMediaType, @@ -1655,19 +1815,18 @@ class DetailsViewModel @Inject constructor( return@launch } // Look up air date for daily show stream resolution fallback - val episodeAirDate = _uiState.value.episodes - .firstOrNull { it.seasonNumber == (season ?: 1) && it.episodeNumber == (episode ?: 1) } - ?.airDate?.takeIf { it.isNotBlank() } + val episodeAirDate = selectedEpisode?.airDate?.takeIf { it.isNotBlank() } streamRepository.resolveEpisodeStreamsProgressive( imdbId = effectiveStreamId, - season = season ?: 1, - episode = episode ?: 1, + season = canonicalSeason ?: 1, + episode = canonicalEpisode ?: 1, tmdbId = currentMediaId, tvdbId = _uiState.value.tvdbId, genreIds = genreIds, originalLanguage = originalLanguage, title = item?.title ?: "", + animeQueryOverride = animeQueryOverride, airDate = episodeAirDate ).collect { progressive -> if (!isCurrentRequest()) return@collect @@ -1710,23 +1869,30 @@ class DetailsViewModel @Inject constructor( fun markEpisodeWatched(season: Int, episode: Int, watched: Boolean) { viewModelScope.launch { try { + val selectedEpisode = _uiState.value.episodes.firstOrNull { + it.seasonNumber == season && it.episodeNumber == episode + } + val canonicalSeason = selectedEpisode?.tmdbSeasonNumber ?: season + val canonicalEpisode = selectedEpisode?.tmdbEpisodeNumber ?: episode if (watched) { - traktRepository.markEpisodeWatched(currentMediaId, season, episode) + traktRepository.markEpisodeWatched(currentMediaId, canonicalSeason, canonicalEpisode) // Also remove from Supabase watch_history (removes from Continue Watching) - watchHistoryRepository.removeFromHistory(currentMediaId, season, episode) + watchHistoryRepository.removeFromHistory(currentMediaId, canonicalSeason, canonicalEpisode) // Save the NEXT episode to CW (local + cloud) so it appears on all devices val item = _uiState.value.item if (item != null) { try { - val nextEp = episode + 1 + val nextIdentity = animeSeasonStructure?.nextAfterDisplay(season, episode) + val nextSeason = nextIdentity?.tmdbSeason ?: canonicalSeason + val nextEp = nextIdentity?.tmdbEpisode ?: canonicalEpisode + 1 traktRepository.saveLocalContinueWatching( mediaType = MediaType.TV, tmdbId = currentMediaId, title = item.title, posterPath = item.image, backdropPath = item.backdrop, - season = season, + season = nextSeason, episode = nextEp, episodeTitle = null, progress = 3, @@ -1740,7 +1906,7 @@ class DetailsViewModel @Inject constructor( title = item.title, poster = item.image, backdrop = item.backdrop, - season = season, + season = nextSeason, episode = nextEp, episodeTitle = null, progress = 0.01f, @@ -1750,7 +1916,7 @@ class DetailsViewModel @Inject constructor( } catch (_: Exception) {} } } else { - traktRepository.markEpisodeUnwatched(currentMediaId, season, episode) + traktRepository.markEpisodeUnwatched(currentMediaId, canonicalSeason, canonicalEpisode) } // Update local state @@ -1809,29 +1975,50 @@ class DetailsViewModel @Inject constructor( // BATCH: Use single Trakt API call for all episodes, then concurrent Supabase writes. // Previously this looped sequentially calling markEpisodeWatched() per episode, // each making its own Supabase + Trakt network call — taking ~5-12s for a full season. - val episodeNumbers = seasonEpisodes.map { it.episodeNumber } - - // 1. Single batch Trakt API call (all episodes in one request) - runCatching { - traktRepository.markSeasonWatched(currentMediaId, season, episodeNumbers) - } - - // 2. Remove from watch history FIRST (synchronous), before Supabase writes. - // This avoids a race condition where removeFromHistory deletes the - // just-written watched records, causing watched status to be lost on re-entry. - runCatching { - watchHistoryRepository.removeFromHistory(currentMediaId, season, null) - } + val canonicalGroups = seasonEpisodes.groupBy { it.tmdbSeasonNumber } - // 3. Concurrent Supabase writes for each episode (faster than sequential). - // These run AFTER removeFromHistory to ensure the watched records are the final state. - episodeNumbers.map { epNum -> - async { + if (animeSeasonStructure == null) { + // Preserve the existing single-season fast path for ordinary TV shows. + val episodeNumbers = seasonEpisodes.map { it.episodeNumber } + runCatching { + traktRepository.markSeasonWatched(currentMediaId, season, episodeNumbers) + } + runCatching { + watchHistoryRepository.removeFromHistory(currentMediaId, season, null) + } + episodeNumbers.map { epNum -> + async { + runCatching { + traktRepository.markEpisodeWatchedWithoutTraktSync(currentMediaId, season, epNum) + } + } + }.forEach { it.await() } + } else { + // A displayed anime season may be a cour inside a larger TMDB season. Never + // clear or mark the rest of that canonical season by accident. + canonicalGroups.forEach { (tmdbSeason, episodes) -> + val tmdbEpisodes = episodes.map { it.tmdbEpisodeNumber } runCatching { - traktRepository.markEpisodeWatchedWithoutTraktSync(currentMediaId, season, epNum) + traktRepository.markSeasonWatched(currentMediaId, tmdbSeason, tmdbEpisodes) } + episodes.map { ep -> + async { + runCatching { + watchHistoryRepository.removeFromHistory( + currentMediaId, + ep.tmdbSeasonNumber, + ep.tmdbEpisodeNumber + ) + traktRepository.markEpisodeWatchedWithoutTraktSync( + currentMediaId, + ep.tmdbSeasonNumber, + ep.tmdbEpisodeNumber + ) + } + } + }.forEach { it.await() } } - }.forEach { it.await() } + } val refreshedProgress = runCatching { fetchSeasonProgress(currentMediaId) }.getOrNull() val nextUnwatched = refreshedProgress?.nextUnwatched @@ -1880,14 +2067,23 @@ class DetailsViewModel @Inject constructor( } val playTarget = buildPlayTarget(currentMediaType, refreshedProgress, null) + val displayPlayTarget = playTarget?.let { target -> + val targetSeason = target.season ?: return@let null + val targetEpisode = target.episode ?: return@let null + animeSeasonStructure?.identityForTmdb(targetSeason, targetEpisode) + } _uiState.value = _uiState.value.copy( item = currentItem.copy(isWatched = nextUnwatched == null), episodes = updatedEpisodes, seasonProgress = refreshedProgress?.progress ?: optimisticProgress, - playSeason = playTarget?.season ?: _uiState.value.playSeason, - playEpisode = playTarget?.episode ?: _uiState.value.playEpisode, - playLabel = playTarget?.label ?: _uiState.value.playLabel, + playSeason = displayPlayTarget?.displaySeason ?: playTarget?.season ?: _uiState.value.playSeason, + playEpisode = displayPlayTarget?.displayEpisode ?: playTarget?.episode ?: _uiState.value.playEpisode, + playTmdbSeason = playTarget?.season ?: _uiState.value.playTmdbSeason, + playTmdbEpisode = playTarget?.episode ?: _uiState.value.playTmdbEpisode, + playLabel = displayPlayTarget?.let { + context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + } ?: playTarget?.label ?: _uiState.value.playLabel, playPositionMs = playTarget?.positionMs ?: _uiState.value.playPositionMs, toastMessage = context.getString(R.string.details_season_marked_watched, season), toastType = ToastType.SUCCESS diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index dd3481279..476d88b31 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -243,6 +243,8 @@ fun PlayerScreen( mediaId: Int, seasonNumber: Int? = null, episodeNumber: Int? = null, + tmdbSeasonNumber: Int? = seasonNumber, + tmdbEpisodeNumber: Int? = episodeNumber, imdbId: String? = null, streamUrl: String? = null, preferredAddonId: String? = null, @@ -252,7 +254,7 @@ fun PlayerScreen( isLiveStream: Boolean = false, viewModel: PlayerViewModel = hiltViewModel(), onBack: () -> Unit = {}, - onPlayNext: (Int, Int, String?, String?, String?) -> Unit = { _, _, _, _, _ -> } + onPlayNext: (Int, Int, Int, Int, String?, String?, String?) -> Unit = { _, _, _, _, _, _, _ -> } ) { val playerAccent = LocalAccentColorOverride.current ?: Color.White val context = LocalContext.current @@ -402,6 +404,8 @@ fun PlayerScreen( var showNextEpisodePrompt by remember { mutableStateOf(false) } var pendingNextSeason by remember { mutableIntStateOf(0) } var pendingNextEpisode by remember { mutableIntStateOf(0) } + var pendingNextTmdbSeason by remember { mutableIntStateOf(0) } + var pendingNextTmdbEpisode by remember { mutableIntStateOf(0) } var pendingNextAddonId by remember { mutableStateOf(null) } var pendingNextSourceName by remember { mutableStateOf(null) } var pendingNextBingeGroup by remember { mutableStateOf(null) } @@ -412,6 +416,8 @@ fun PlayerScreen( onPlayNext( pendingNextSeason, pendingNextEpisode, + pendingNextTmdbSeason, + pendingNextTmdbEpisode, pendingNextAddonId, pendingNextSourceName, pendingNextBingeGroup @@ -643,7 +649,7 @@ fun PlayerScreen( } // Load media - LaunchedEffect(mediaType, mediaId, seasonNumber, episodeNumber, imdbId, preferredAddonId, preferredSourceName, preferredBingeGroup, startPositionMs, isLiveStream) { + LaunchedEffect(mediaType, mediaId, seasonNumber, episodeNumber, tmdbSeasonNumber, tmdbEpisodeNumber, imdbId, preferredAddonId, preferredSourceName, preferredBingeGroup, startPositionMs, isLiveStream) { playbackIssueReported = false startupRecoverAttempted = false startupHardFailureReported = false @@ -667,8 +673,8 @@ fun PlayerScreen( viewModel.loadMedia( mediaType = mediaType, mediaId = mediaId, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, + seasonNumber = tmdbSeasonNumber, + episodeNumber = tmdbEpisodeNumber, providedImdbId = imdbId, providedStreamUrl = streamUrl, preferredAddonId = preferredAddonId, @@ -2350,6 +2356,8 @@ fun PlayerScreen( val selected = uiState.selectedStream pendingNextSeason = endedEpisodeKey.seasonNumber pendingNextEpisode = endedEpisodeKey.episodeNumber + 1 + pendingNextTmdbSeason = tmdbSeasonNumber ?: endedEpisodeKey.seasonNumber + pendingNextTmdbEpisode = (tmdbEpisodeNumber ?: endedEpisodeKey.episodeNumber) + 1 pendingNextAddonId = selected?.addonId?.takeIf { it.isNotBlank() } pendingNextSourceName = selected?.source?.takeIf { it.isNotBlank() } pendingNextBingeGroup = selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() } @@ -2613,6 +2621,8 @@ fun PlayerScreen( onPlayNext( seasonNumber, episodeNumber + 1, + tmdbSeasonNumber ?: seasonNumber, + (tmdbEpisodeNumber ?: episodeNumber) + 1, selected?.addonId?.takeIf { it.isNotBlank() }, selected?.source?.takeIf { it.isNotBlank() }, selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() } @@ -2627,6 +2637,8 @@ fun PlayerScreen( onPlayNext( seasonNumber, episodeNumber - 1, + tmdbSeasonNumber ?: seasonNumber, + (tmdbEpisodeNumber ?: episodeNumber) - 1, selected?.addonId?.takeIf { it.isNotBlank() }, selected?.source?.takeIf { it.isNotBlank() }, selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() } @@ -3612,7 +3624,15 @@ fun PlayerScreen( val season = seasonNumber ?: return@PlayerIconButton val episode = episodeNumber ?: return@PlayerIconButton val selected = uiState.selectedStream - onPlayNext(season, episode + 1, selected?.addonId?.takeIf { it.isNotBlank() }, selected?.source?.takeIf { it.isNotBlank() }, selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() }) + onPlayNext( + season, + episode + 1, + tmdbSeasonNumber ?: season, + (tmdbEpisodeNumber ?: episode) + 1, + selected?.addonId?.takeIf { it.isNotBlank() }, + selected?.source?.takeIf { it.isNotBlank() }, + selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() } + ) }, onLeftKey = { aspectButtonFocusRequester.requestFocus() }, onRightKey = { subtitleButtonFocusRequester.requestFocus() }, diff --git a/app/src/main/kotlin/com/arflix/tv/util/AnimeMapper.kt b/app/src/main/kotlin/com/arflix/tv/util/AnimeMapper.kt index ae8eb9787..1d1401307 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/AnimeMapper.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/AnimeMapper.kt @@ -411,6 +411,34 @@ class AnimeMapper @Inject constructor( return isAnimation && isJapanese } + /** + * Resolve the optional user-visible anime season structure for a TMDB show. + * Returns null unless ARM exposes multiple Kitsu entries with complete episode counts. + */ + internal suspend fun resolveAnimeSeasonStructure(tmdbId: Int): AnimeSeasonStructure? = withContext(Dispatchers.IO) { + try { + val entries = cacheMutex.withLock { armTmdbCache[tmdbId] } + ?: fetchArmMapping(tmdbId) + ?: return@withContext null + val providerEntries = entries + .mapNotNull { entry -> + val kitsuId = entry.kitsu ?: return@mapNotNull null + val count = getKitsuEpisodeCount(kitsuId) ?: return@mapNotNull null + AnimeProviderSeason(kitsuId, entry.themoviedbSeason, count) + } + .distinctBy { Triple(it.kitsuId, it.tmdbSeason, it.episodeCount) } + if (providerEntries.size < 2) return@withContext null + + val details = tmdbApi.getTvDetails(tmdbId, Constants.TMDB_API_KEY) + val tmdbCounts = details.seasons + .filter { it.seasonNumber > 0 && it.episodeCount > 0 } + .associate { it.seasonNumber to it.episodeCount } + buildAnimeSeasonStructure(tmdbCounts, providerEntries) + } catch (e: Exception) { + null + } + } + /** * Main resolution function: resolves the correct Kitsu episode query string * for Stremio addons. Uses 5-tier fallback chain. diff --git a/app/src/main/kotlin/com/arflix/tv/util/AnimeSeasonStructure.kt b/app/src/main/kotlin/com/arflix/tv/util/AnimeSeasonStructure.kt new file mode 100644 index 000000000..640791d60 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/util/AnimeSeasonStructure.kt @@ -0,0 +1,119 @@ +package com.arflix.tv.util + +/** A provider entry that can become one user-visible anime season. */ +internal data class AnimeProviderSeason( + val kitsuId: Int, + val tmdbSeason: Int?, + val episodeCount: Int +) + +/** Maps one user-visible anime episode back to the canonical TMDB episode. */ +internal data class AnimeEpisodeIdentity( + val displaySeason: Int, + val displayEpisode: Int, + val tmdbSeason: Int, + val tmdbEpisode: Int, + val kitsuId: Int +) + +internal data class AnimeSeasonStructure( + val seasons: Map> +) { + val seasonCount: Int get() = seasons.size + + fun identityForDisplay(season: Int, episode: Int): AnimeEpisodeIdentity? = + seasons[season]?.firstOrNull { it.displayEpisode == episode } + + fun identityForTmdb(season: Int, episode: Int): AnimeEpisodeIdentity? = + seasons.values.asSequence().flatten().firstOrNull { + it.tmdbSeason == season && it.tmdbEpisode == episode + } + + fun nextAfterDisplay(season: Int, episode: Int): AnimeEpisodeIdentity? { + identityForDisplay(season, episode + 1)?.let { return it } + return seasons[season + 1]?.firstOrNull() + } +} + +/** + * Builds an anime-facing season structure without title-specific rules. + * + * TMDB remains the canonical identity used by Trakt/history. ARM/Kitsu only controls how those + * canonical episodes are grouped and numbered for display and addon lookup. If the provider data + * is incomplete or contradictory, null is returned so callers safely keep the normal TMDB view. + */ +internal fun buildAnimeSeasonStructure( + tmdbSeasonEpisodeCounts: Map, + providerSeasons: List +): AnimeSeasonStructure? { + val canonicalEpisodes = tmdbSeasonEpisodeCounts + .filterKeys { it > 0 } + .toSortedMap() + .flatMap { (season, count) -> + (1..count.coerceAtLeast(0)).map { episode -> season to episode } + } + if (canonicalEpisodes.isEmpty()) return null + + val validProviders = providerSeasons + .filter { it.kitsuId > 0 && it.episodeCount > 0 } + .distinctBy { Triple(it.kitsuId, it.tmdbSeason, it.episodeCount) } + if (validProviders.size < 2) return null + + val explicitlyMapped = validProviders.all { it.tmdbSeason != null } + if (explicitlyMapped) { + val result = linkedMapOf>() + var displaySeason = 1 + var explicitMappingValid = true + validProviders.groupBy { it.tmdbSeason!! }.toSortedMap().forEach { (tmdbSeason, entries) -> + val available = tmdbSeasonEpisodeCounts[tmdbSeason] + if (available == null || entries.sumOf { it.episodeCount } > available) { + explicitMappingValid = false + return@forEach + } + var tmdbEpisode = 1 + entries.forEach { provider -> + result[displaySeason] = (1..provider.episodeCount).map { displayEpisode -> + AnimeEpisodeIdentity( + displaySeason = displaySeason, + displayEpisode = displayEpisode, + tmdbSeason = tmdbSeason, + tmdbEpisode = tmdbEpisode++, + kitsuId = provider.kitsuId + ) + } + displaySeason++ + } + } + if (explicitMappingValid) { + return AnimeSeasonStructure(result).takeIf { it.seasonCount > 1 } + } + } + + // Some TMDB records merge multiple official anime seasons into one canonical season even + // though ARM labels each Kitsu entry with its official season. When the episode totals match + // exactly, preserve TMDB identity by distributing the official seasons over the canonical + // episode sequence. Any incomplete or contradictory provider data still falls back to TMDB. + if (validProviders.sumOf { it.episodeCount } != canonicalEpisodes.size) return null + val result = linkedMapOf>() + var canonicalIndex = 0 + validProviders.forEachIndexed { index, provider -> + val remaining = canonicalEpisodes.size - canonicalIndex + val take = provider.episodeCount + if (take <= 0 || (index != validProviders.lastIndex && take > remaining)) return null + val displaySeason = index + 1 + result[displaySeason] = (0 until take).map { offset -> + val (tmdbSeason, tmdbEpisode) = canonicalEpisodes[canonicalIndex + offset] + AnimeEpisodeIdentity( + displaySeason = displaySeason, + displayEpisode = offset + 1, + tmdbSeason = tmdbSeason, + tmdbEpisode = tmdbEpisode, + kitsuId = provider.kitsuId + ) + } + canonicalIndex += take + } + if (canonicalIndex != canonicalEpisodes.size) return null + + return AnimeSeasonStructure(result).takeIf { it.seasonCount > 1 } +} diff --git a/app/src/test/kotlin/com/arflix/tv/util/AnimeSeasonStructureTest.kt b/app/src/test/kotlin/com/arflix/tv/util/AnimeSeasonStructureTest.kt new file mode 100644 index 000000000..c97fdd291 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/util/AnimeSeasonStructureTest.kt @@ -0,0 +1,135 @@ +package com.arflix.tv.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AnimeSeasonStructureTest { + + @Test + fun `My Dress-Up Darling splits one TMDB season into two anime seasons`() { + val result = buildAnimeSeasonStructure( + tmdbSeasonEpisodeCounts = mapOf(1 to 24), + providerSeasons = listOf( + AnimeProviderSeason(kitsuId = 44382, tmdbSeason = 1, episodeCount = 12), + AnimeProviderSeason(kitsuId = 46492, tmdbSeason = 2, episodeCount = 12) + ) + )!! + + assertEquals(2, result.seasonCount) + assertEquals(12, result.seasons.getValue(1).size) + assertEquals(12, result.seasons.getValue(2).size) + val seasonTwoFirst = result.seasons.getValue(2).first() + assertEquals(1, seasonTwoFirst.displayEpisode) + assertEquals(1, seasonTwoFirst.tmdbSeason) + assertEquals(13, seasonTwoFirst.tmdbEpisode) + } + + @Test + fun `Rent-a-Girlfriend creates one display season per provider entry`() { + val result = buildAnimeSeasonStructure( + tmdbSeasonEpisodeCounts = mapOf(1 to 48), + providerSeasons = listOf( + AnimeProviderSeason(43019, null, 12), + AnimeProviderSeason(45588, null, 12), + AnimeProviderSeason(47194, null, 12), + AnimeProviderSeason(48817, null, 12) + ) + )!! + + assertEquals(4, result.seasonCount) + assertTrue(result.seasons.values.all { it.size == 12 }) + assertEquals(37, result.seasons.getValue(4).first().tmdbEpisode) + } + + @Test + fun `explicit ARM TMDB season entries preserve canonical tracking identity`() { + val result = buildAnimeSeasonStructure( + tmdbSeasonEpisodeCounts = mapOf(1 to 12, 2 to 24), + providerSeasons = listOf( + AnimeProviderSeason(100, 1, 12), + AnimeProviderSeason(200, 2, 12), + AnimeProviderSeason(201, 2, 12) + ) + )!! + + assertEquals(3, result.seasonCount) + assertEquals(2, result.seasons.getValue(3).first().tmdbSeason) + assertEquals(13, result.seasons.getValue(3).first().tmdbEpisode) + } + + @Test + fun `Mushoku Tensei cours can split one canonical season without losing TMDB identity`() { + val result = buildAnimeSeasonStructure( + tmdbSeasonEpisodeCounts = mapOf(1 to 23), + providerSeasons = listOf( + AnimeProviderSeason(1, null, 11), + AnimeProviderSeason(2, null, 12) + ) + )!! + + assertEquals(2, result.seasonCount) + assertEquals(12, result.seasons.getValue(2).size) + assertEquals(12, result.seasons.getValue(2).first().tmdbEpisode) + assertEquals(23, result.seasons.getValue(2).last().tmdbEpisode) + } + + @Test + fun `incomplete provider data safely falls back to TMDB`() { + assertNull( + buildAnimeSeasonStructure( + tmdbSeasonEpisodeCounts = mapOf(1 to 24), + providerSeasons = listOf( + AnimeProviderSeason(100, null, 12), + AnimeProviderSeason(200, null, 20) + ) + ) + ) + } + + @Test + fun `single long-running provider entry does not invent seasons`() { + assertNull( + buildAnimeSeasonStructure( + tmdbSeasonEpisodeCounts = mapOf(1 to 61, 2 to 16), + providerSeasons = listOf(AnimeProviderSeason(12, null, 77)) + ) + ) + } + + @Test + fun `Naruto style long series remains on its TMDB structure`() { + assertNull( + buildAnimeSeasonStructure( + tmdbSeasonEpisodeCounts = mapOf(1 to 35, 2 to 65, 3 to 41, 4 to 42, 5 to 37), + providerSeasons = listOf(AnimeProviderSeason(11, null, 220)) + ) + ) + } + + @Test + fun `classic series without anime provider seasons are unchanged`() { + listOf( + mapOf(1 to 7, 2 to 13, 3 to 13, 4 to 13, 5 to 16), // Breaking Bad + mapOf(1 to 9, 2 to 7), // The Last of Us + mapOf(1 to 10, 2 to 10, 3 to 10, 4 to 10, 5 to 10, 6 to 10, 7 to 7, 8 to 6) + ).forEach { tmdbSeasons -> + assertNull(buildAnimeSeasonStructure(tmdbSeasons, emptyList())) + } + } + + @Test + fun `next display episode resolves to canonical Trakt coordinates across a cour boundary`() { + val result = buildAnimeSeasonStructure( + mapOf(1 to 24), + listOf(AnimeProviderSeason(1, null, 12), AnimeProviderSeason(2, null, 12)) + )!! + + val next = result.nextAfterDisplay(1, 12)!! + assertEquals(2, next.displaySeason) + assertEquals(1, next.displayEpisode) + assertEquals(1, next.tmdbSeason) + assertEquals(13, next.tmdbEpisode) + } +}