diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt index 013bcace5..950b304dd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklMutationRepository.kt @@ -231,11 +231,17 @@ internal fun buildSimklScrobbleBody( json: Json = SimklMutationJson, ): String { val media = event.media.toScrobbleMediaDto() + val usesTvStyleAnimeCoordinates = event.media.kind == TrackingMediaKind.ANIME && + event.media.episode?.season != null val request = SimklScrobbleRequestDto( progress = event.progressPercent.clampAndRoundProgress(), movie = media.takeIf { event.media.kind == TrackingMediaKind.MOVIE }, - show = media.takeIf { event.media.kind == TrackingMediaKind.SHOW }, - anime = media.takeIf { event.media.kind == TrackingMediaKind.ANIME }, + show = media.takeIf { + event.media.kind == TrackingMediaKind.SHOW || usesTvStyleAnimeCoordinates + }, + anime = media.takeIf { + event.media.kind == TrackingMediaKind.ANIME && !usesTvStyleAnimeCoordinates + }, episode = event.media.episode?.toEpisodeDto( includeSeason = true, includeWatchedAt = false, @@ -316,6 +322,7 @@ private fun buildShowHistoryItem( includeWatchedAt = includeWatchedAt, episodes = flatEpisodes, seasons = seasons, + useTvdbAnimeSeasons = first.media.kind == TrackingMediaKind.ANIME && seasons.isNotEmpty(), ) } @@ -325,6 +332,7 @@ private fun TrackingMediaReference.toHistoryItemDto( status: String? = null, episodes: List = emptyList(), seasons: List = emptyList(), + useTvdbAnimeSeasons: Boolean = false, ): SimklHistoryItemDto = SimklHistoryItemDto( title = title.nonBlankOrNull(), year = year, @@ -333,6 +341,7 @@ private fun TrackingMediaReference.toHistoryItemDto( status = status, episodes = episodes, seasons = seasons, + useTvdbAnimeSeasons = useTvdbAnimeSeasons, ) private fun TrackingMediaReference.toScrobbleMediaDto(): SimklScrobbleMediaDto = @@ -508,6 +517,7 @@ private data class SimklHistoryItemDto( val status: String? = null, val episodes: List = emptyList(), val seasons: List = emptyList(), + @SerialName("use_tvdb_anime_seasons") val useTvdbAnimeSeasons: Boolean = false, ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt index f4a8ea3b5..885350f02 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklProjections.kt @@ -48,9 +48,9 @@ internal fun SimklSyncSnapshot.toSimklWatchedProjection(): SimklWatchedProjectio var hasEpisodeHistory = false entry.seasons.forEach seasonLoop@{ season -> - val seasonNumber = season.number ?: return@seasonLoop season.episodes.forEach episodeLoop@{ episode -> - val episodeNumber = episode.number ?: return@episodeLoop + val seasonNumber = episode.tvdb?.season ?: season.number ?: return@episodeLoop + val episodeNumber = episode.tvdb?.episode ?: episode.number ?: return@episodeLoop val watchedAt = parseSimklUtcEpochMs(episode.watchedAt) ?: return@episodeLoop hasEpisodeHistory = true watchedItems += WatchedItem( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt index 81228c955..37f7a895e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklMutationRepositoryTest.kt @@ -67,6 +67,40 @@ class SimklMutationRepositoryTest { assertEquals("2023-11-14T22:13:20Z", movieItem.getValue("watched_at").jsonPrimitive.content) } + @Test + fun `anime history uses tvdb mapping flag only for seasonal coordinates`() { + val seasonalBody = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem( + anime(TrackingEpisode(season = 2, number = 4)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ).asObject() + val seasonalAnime = seasonalBody.getValue("shows").jsonArray.single().jsonObject + assertTrue(seasonalAnime.getValue("use_tvdb_anime_seasons").jsonPrimitive.content.toBoolean()) + + val flatBody = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem( + anime(TrackingEpisode(number = 4)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ).asObject() + assertNull(flatBody.getValue("shows").jsonArray.single().jsonObject["use_tvdb_anime_seasons"]) + + val showBody = buildSimklHistoryMutationBody( + listOf( + TrackingHistoryItem( + show(TrackingEpisode(season = 2, number = 4)), + watchedAtEpochMs = 1_700_000_000_000L, + ), + ), + ).asObject() + assertNull(showBody.getValue("shows").jsonArray.single().jsonObject["use_tvdb_anime_seasons"]) + } + @Test fun `anime removal uses shows array and contains no response-only or watch fields`() { val body = buildSimklHistoryRemovalBody( @@ -78,6 +112,12 @@ class SimklMutationRepositoryTest { assertNull(item["watched_at"]) assertNull(item["status"]) assertEquals(4, item.getValue("episodes").jsonArray.single().jsonObject.getValue("number").jsonPrimitive.content.toInt()) + + val seasonalBody = buildSimklHistoryRemovalBody( + listOf(anime(TrackingEpisode(season = 2, number = 4))), + ).asObject() + val seasonalItem = seasonalBody.getValue("shows").jsonArray.single().jsonObject + assertTrue(seasonalItem.getValue("use_tvdb_anime_seasons").jsonPrimitive.content.toBoolean()) } @Test @@ -89,16 +129,27 @@ class SimklMutationRepositoryTest { assertTrue("movie" in movieBody) assertFalse("show" in movieBody) - val animeBody = buildSimklScrobbleBody( + val tvStyleAnimeBody = buildSimklScrobbleBody( TrackingScrobbleEvent( anime(TrackingEpisode(season = 2, number = 4)), progressPercent = 42.236, ), ).asObject() - assertEquals(42.24, animeBody.getValue("progress").jsonPrimitive.content.toDouble()) - assertTrue("anime" in animeBody) - assertEquals(2, animeBody.getValue("episode").jsonObject.getValue("season").jsonPrimitive.content.toInt()) - assertEquals(4, animeBody.getValue("episode").jsonObject.getValue("number").jsonPrimitive.content.toInt()) + assertEquals(42.24, tvStyleAnimeBody.getValue("progress").jsonPrimitive.content.toDouble()) + assertTrue("show" in tvStyleAnimeBody) + assertFalse("anime" in tvStyleAnimeBody) + assertEquals(2, tvStyleAnimeBody.getValue("episode").jsonObject.getValue("season").jsonPrimitive.content.toInt()) + assertEquals(4, tvStyleAnimeBody.getValue("episode").jsonObject.getValue("number").jsonPrimitive.content.toInt()) + + val nativeAnimeBody = buildSimklScrobbleBody( + TrackingScrobbleEvent( + anime(TrackingEpisode(number = 4)), + progressPercent = 42.236, + ), + ).asObject() + assertTrue("anime" in nativeAnimeBody) + assertFalse("show" in nativeAnimeBody) + assertNull(nativeAnimeBody.getValue("episode").jsonObject["season"]) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index 8f66b3ee4..af2db560e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -142,6 +142,36 @@ class SimklProjectionsTest { assertTrue(projection.fullyWatchedSeriesKeys.isEmpty()) } + @Test + fun `anime watched projection prefers mapped tvdb coordinates`() { + val anime = entry( + type = SimklMediaType.ANIME, + status = SimklListStatus.WATCHING, + id = 439744, + imdb = "tt2560140", + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode( + number = 4, + watchedAt = "2023-11-14T23:13:20Z", + tvdb = SimklEpisodeMapping(season = 2, episode = 4), + ), + ), + ), + ), + ) + + val watched = SimklSyncSnapshot(entries = listOf(anime)) + .toSimklWatchedProjection() + .items + .single() + + assertEquals(2, watched.season) + assertEquals(4, watched.episode) + } + @Test fun `playback projection preserves Simkl session identity and percentage`() { val session = SimklPlaybackSession(