From d0afda0db56439dae84fa0d8b4c23356fae98b22 Mon Sep 17 00:00:00 2001 From: Oleh Liubchenko Date: Sun, 1 Nov 2026 12:45:17 +0100 Subject: [PATCH] fix(AniList): library parsing and list updates - Make nullable the AniList fields that can be null (dates, episodes, seasonYear, updatedAt). Since the move to kotlinx serialization these are no longer coerced to 0, which made the library list fail to parse for practically every account. - SaveMediaListEntry: only declare and pass scoreRaw / progress when they have a value. AniList rejected the whole mutation otherwise ("Variable $scoreRaw is not defined"), while it was reported as success. - Treat GraphQL errors in the mutation response as a failure. Co-Authored-By: Claude Opus 5.5 --- .../syncproviders/providers/AniListApi.kt | 75 ++++++++++++------- .../cloudstream3/AniListParsingTest.kt | 67 +++++++++++++++++ 2 files changed, 117 insertions(+), 25 deletions(-) create mode 100644 app/src/test/java/com/lagradost/cloudstream3/AniListParsingTest.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt index d3e7f22c7ee..d72356b1305 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -307,8 +307,7 @@ class AniListApi : SyncAPI() { val filtered = shows?.data?.page?.media?.filter { - (((it.startDate.year ?: year.toString()) == year.toString() - || year == null)) + year == null || (it.startDate?.year ?: year) == year } filtered?.forEach { it.title.romaji?.let { romaji -> @@ -547,16 +546,16 @@ class AniListApi : SyncAPI() { @Serializable data class CompletedAt( - @JsonProperty("year") @SerialName("year") val year: Int, - @JsonProperty("month") @SerialName("month") val month: Int, - @JsonProperty("day") @SerialName("day") val day: Int, + @JsonProperty("year") @SerialName("year") val year: Int?, + @JsonProperty("month") @SerialName("month") val month: Int?, + @JsonProperty("day") @SerialName("day") val day: Int?, ) @Serializable data class StartedAt( - @JsonProperty("year") @SerialName("year") val year: String?, - @JsonProperty("month") @SerialName("month") val month: String?, - @JsonProperty("day") @SerialName("day") val day: String?, + @JsonProperty("year") @SerialName("year") val year: Int?, + @JsonProperty("month") @SerialName("month") val month: Int?, + @JsonProperty("day") @SerialName("day") val day: Int?, ) @Serializable @@ -577,9 +576,9 @@ class AniListApi : SyncAPI() { @JsonProperty("id") @SerialName("id") val id: Int, @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?, @JsonProperty("season") @SerialName("season") val season: String?, - @JsonProperty("seasonYear") @SerialName("seasonYear") val seasonYear: Int, + @JsonProperty("seasonYear") @SerialName("seasonYear") val seasonYear: Int?, @JsonProperty("format") @SerialName("format") val format: String?, - @JsonProperty("episodes") @SerialName("episodes") val episodes: Int, + @JsonProperty("episodes") @SerialName("episodes") val episodes: Int?, @JsonProperty("title") @SerialName("title") val title: Title, @JsonProperty("description") @SerialName("description") val description: String?, @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: CoverImage, @@ -590,9 +589,9 @@ class AniListApi : SyncAPI() { @Serializable data class Entries( @JsonProperty("status") @SerialName("status") val status: String?, - @JsonProperty("completedAt") @SerialName("completedAt") val completedAt: CompletedAt, - @JsonProperty("startedAt") @SerialName("startedAt") val startedAt: StartedAt, - @JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: Int, + @JsonProperty("completedAt") @SerialName("completedAt") val completedAt: CompletedAt?, + @JsonProperty("startedAt") @SerialName("startedAt") val startedAt: StartedAt?, + @JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: Int?, @JsonProperty("progress") @SerialName("progress") val progress: Int, @JsonProperty("score") @SerialName("score") val score: Int, @JsonProperty("private") @SerialName("private") val private: Boolean, @@ -609,14 +608,14 @@ class AniListApi : SyncAPI() { this.progress, this.media.episodes, Score.from100(this.score), - this.updatedAt.toLong(), + this.updatedAt?.toLong(), "AniList", TvType.Anime, this.media.coverImage.extraLarge ?: this.media.coverImage.large ?: this.media.coverImage.medium, null, null, - this.media.seasonYear.toYear(), + this.media.seasonYear?.toYear(), null, plot = this.media.description, ) @@ -792,13 +791,23 @@ class AniListApi : SyncAPI() { } """ } else { - """mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${ - aniListStatusString[maxOf( - 0, - type.value - )] - }, ${if (score != null) "${'$'}scoreRaw: Int = ${score.toInt(100)}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { - SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) { + // Only declare and pass the optional variables that have a value, + // AniList rejects the whole mutation if an undeclared variable is used + val variables = mutableListOf( + "${'$'}id: Int = $id", + "${'$'}status: MediaListStatus = ${aniListStatusString[maxOf(0, type.value)]}", + ) + val arguments = mutableListOf("mediaId: ${'$'}id", "status: ${'$'}status") + if (score != null) { + variables += "${'$'}scoreRaw: Int = ${score.toInt(100)}" + arguments += "scoreRaw: ${'$'}scoreRaw" + } + if (progress != null) { + variables += "${'$'}progress: Int = $progress" + arguments += "progress: ${'$'}progress" + } + """mutation (${variables.joinToString()}) { + SaveMediaListEntry (${arguments.joinToString()}) { id status progress @@ -808,9 +817,25 @@ class AniListApi : SyncAPI() { } val data = postApi(auth.token, q) - return data != "" + if (data.isNullOrBlank()) return false + val errors = tryParseJson(data)?.errors + if (!errors.isNullOrEmpty()) { + logError(ErrorLoadingException("AniList: ${errors.joinToString { it.message.toString() }}")) + return false + } + return true } + @Serializable + data class GraphQLError( + @JsonProperty("message") @SerialName("message") val message: String?, + ) + + @Serializable + data class GraphQLErrorRoot( + @JsonProperty("errors") @SerialName("errors") val errors: List?, + ) + private suspend fun getUser(token: AuthToken): AniListUser? { val q = """ { @@ -1187,9 +1212,9 @@ class AniListApi : SyncAPI() { data class GetSearchMedia( @JsonProperty("id") @SerialName("id") val id: Int, @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?, - @JsonProperty("seasonYear") @SerialName("seasonYear") val seasonYear: Int, + @JsonProperty("seasonYear") @SerialName("seasonYear") val seasonYear: Int?, @JsonProperty("title") @SerialName("title") val title: GetSearchTitle, - @JsonProperty("startDate") @SerialName("startDate") val startDate: StartedAt, + @JsonProperty("startDate") @SerialName("startDate") val startDate: StartedAt?, @JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?, @JsonProperty("meanScore") @SerialName("meanScore") val meanScore: Int?, @JsonProperty("bannerImage") @SerialName("bannerImage") val bannerImage: String?, diff --git a/app/src/test/java/com/lagradost/cloudstream3/AniListParsingTest.kt b/app/src/test/java/com/lagradost/cloudstream3/AniListParsingTest.kt new file mode 100644 index 00000000000..2dc46b9d28b --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/AniListParsingTest.kt @@ -0,0 +1,67 @@ +package com.lagradost.cloudstream3 + +import com.lagradost.cloudstream3.syncproviders.providers.AniListApi +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Ensure AniList responses containing nulls can be parsed. */ +class AniListParsingTest { + private val listResponse = """ + {"data":{"MediaListCollection":{"lists":[ + {"status":"PLANNING","entries":[{ + "status":"PLANNING", + "completedAt":{"year":null,"month":null,"day":null}, + "startedAt":{"year":null,"month":null,"day":null}, + "updatedAt":1761990000,"progress":0,"score":0,"private":false, + "media":{"id":5114,"idMal":5114,"season":null,"seasonYear":null,"format":"TV", + "episodes":null,"chapters":null, + "title":{"english":null,"romaji":"Hagane no Renkinjutsushi: FULLMETAL ALCHEMIST"}, + "coverImage":{"extraLarge":null,"large":"https://example.com/l.jpg","medium":null}, + "synonyms":[],"nextAiringEpisode":null} + }]}, + {"status":"COMPLETED","entries":[{ + "status":"COMPLETED", + "completedAt":{"year":2024,"month":3,"day":22}, + "startedAt":{"year":2023,"month":9,"day":29}, + "updatedAt":1711100000,"progress":28,"score":95,"private":false, + "media":{"id":154587,"idMal":52991,"season":"FALL","seasonYear":2023,"format":"TV", + "episodes":28,"chapters":null, + "title":{"english":"Frieren: Beyond Journey's End","romaji":"Sousou no Frieren"}, + "coverImage":{"extraLarge":"https://example.com/xl.jpg","large":null,"medium":null}, + "synonyms":["Frieren"],"nextAiringEpisode":null} + }]} + ]}}} + """.trimIndent() + + @Test + fun `parses library list with null dates and episodes`() { + val lists = parseJson(listResponse) + .data!!.mediaListCollection.lists + assertEquals(2, lists.size) + + val planning = lists[0].entries[0] + assertNull(planning.completedAt?.year) + assertNull(planning.media.episodes) + val planningItem = planning.toLibraryItem() + assertEquals("Hagane no Renkinjutsushi: FULLMETAL ALCHEMIST", planningItem.name) + assertNull(planningItem.episodesTotal) + + val completed = lists[1].entries[0] + assertEquals(2024, completed.completedAt?.year) + assertEquals(2023, completed.startedAt?.year) + assertEquals("Frieren: Beyond Journey's End", completed.toLibraryItem().name) + } + + @Test + fun `detects GraphQL errors in mutation response`() { + val error = """{"errors":[{"message":"Variable \"${'$'}scoreRaw\" is not defined.","status":400}],"data":null}""" + val errors = parseJson(error).errors + assertEquals("Variable \"\$scoreRaw\" is not defined.", errors?.single()?.message) + + val success = """{"data":{"SaveMediaListEntry":{"id":1,"status":"PLANNING","progress":0,"score":0}}}""" + assertTrue(parseJson(success).errors.isNullOrEmpty()) + } +}