From d0afda0db56439dae84fa0d8b4c23356fae98b22 Mon Sep 17 00:00:00 2001 From: Oleh Liubchenko Date: Sun, 1 Nov 2026 12:45:17 +0100 Subject: [PATCH 1/3] 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()) + } +} From 2a6faa5add348b56018e583d4e2077c65d8d5c7e Mon Sep 17 00:00:00 2001 From: Oleh Liubchenko Date: Sun, 1 Nov 2026 12:45:20 +0100 Subject: [PATCH 2/3] feat: sync bookmark status to logged in sync accounts Changing a title's bookmark status now sets the same status on every logged in sync account (AniList, MAL, ...) that has an id for the title, then reloads the library once the update has landed. The TV layout has no sync controls, so this was the only way to manage those lists there. Co-Authored-By: Claude Opus 5.5 --- .../ui/result/ResultViewModel2.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 17a698de2df..413aa180770 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -70,6 +70,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.seconds import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL @@ -802,6 +803,7 @@ class ResultViewModel2 : ViewModel() { if (currentStatus != status) { MainActivity.bookmarksUpdatedEvent(true) MainActivity.reloadLibraryEvent(true) + syncWatchStatus(response.syncData, status) } _watchStatus.postValue(status) @@ -810,6 +812,31 @@ class ResultViewModel2 : ViewModel() { } } + /** + * Mirrors a bookmark status change to every logged in sync account (AniList, MAL, ...) + * that has an id for this title. The TV layout has no sync controls of its own, + * so this is the only way to add titles to those lists there. + */ + private fun syncWatchStatus(syncData: Map?, status: WatchType) = ioSafe { + if (syncData.isNullOrEmpty()) return@ioSafe + // WatchType.NONE and SyncWatchType.NONE have different internal ids + val syncStatus = if (status == WatchType.NONE) SyncWatchType.NONE + else SyncWatchType.fromInternalId(status.internalId) + + val results = AccountManager.syncApis.toList().amap { repo -> + val id = syncData[repo.idPrefix] ?: return@amap false + if (repo.authUser() == null) return@amap false + val newStatus = repo.status(id).getOrNull()?.apply { this.status = syncStatus } + ?: SyncAPI.SyncStatus(status = syncStatus, score = null, watchedEpisodes = null) + val result = repo.updateStatus(id, newStatus) + Log.i(TAG, "syncWatchStatus ${repo.name} $id => $syncStatus: $result") + result.getOrNull() == true + } + // The library reload fired by the bookmark change has most likely already fetched + // the remote list before this update landed, so reload it again + if (results.any { it }) MainActivity.reloadLibraryEvent(true) + } + private fun startChromecast( activity: Activity?, result: ResultEpisode, From 787a8e6f1d2234eb8de3f777f829dca56ad646f4 Mon Sep 17 00:00:00 2001 From: Oleh Liubchenko Date: Mon, 2 Nov 2026 20:17:27 +0100 Subject: [PATCH 3/3] feat: put bookmark sync behind a setting (off by default) --- .../lagradost/cloudstream3/ui/result/ResultViewModel2.kt | 7 +++++-- .../cloudstream3/ui/settings/SettingsAccountScreen.kt | 5 +++++ app/src/main/res/values/strings.xml | 2 ++ .../com/lagradost/cloudstream4/DevicePreferenceStore.kt | 4 ++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 413aa180770..cec97eb5b50 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -138,6 +138,7 @@ import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitl import com.lagradost.cloudstream3.utils.loadExtractor import com.lagradost.cloudstream3.utils.newExtractorLink import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream4.AppSettings import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -814,11 +815,13 @@ class ResultViewModel2 : ViewModel() { /** * Mirrors a bookmark status change to every logged in sync account (AniList, MAL, ...) - * that has an id for this title. The TV layout has no sync controls of its own, - * so this is the only way to add titles to those lists there. + * that has an id for this title, if enabled in the settings. The TV layout has no sync + * controls of its own, so this is the only way to add titles to those lists there. */ private fun syncWatchStatus(syncData: Map?, status: WatchType) = ioSafe { if (syncData.isNullOrEmpty()) return@ioSafe + val ctx = context ?: return@ioSafe + if (!AppSettings(ctx).security.syncBookmarks.get()) return@ioSafe // WatchType.NONE and SyncWatchType.NONE have different internal ids val syncStatus = if (status == WatchType.NONE) SyncWatchType.NONE else SyncWatchType.fromInternalId(status.internalId) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccountScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccountScreen.kt index 97890bbfa18..cd15421fb8a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccountScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccountScreen.kt @@ -115,6 +115,11 @@ object SettingsAccountScreen : SearchableSettings, BiometricAuthenticator.Biomet preference = settings.security.skipAccountSelection, title = stringResource(R.string.skip_startup_account_select_pref), icon = painterResource(R.drawable.ic_outline_account_circle_24) + ) + Preference.PreferenceItem.SwitchPreference( + preference = settings.security.syncBookmarks, + title = stringResource(R.string.sync_bookmarks_pref), + subtitle = stringResource(R.string.sync_bookmarks_pref_summary), + icon = painterResource(R.drawable.ic_baseline_bookmark_24) ), ), Preference.PreferenceGroup( enabled = hasSecurity && isLayout(PHONE), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c429519967..776e3b28eb8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -704,6 +704,8 @@ Edit account Logged in as %s Skip account selection at startup + Sync bookmarks to accounts + Changing a bookmark also updates the title on your AniList, MAL and other logged in lists Use Default Account Rotate Display a toggle button for screen orientation diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index cad05c42229..501e71ade7e 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -57,6 +57,10 @@ class SecurityPreferences(preferences: PreferenceStore) { val skipAccountSelection = preferences.getBoolean( "skip_startup_account_select_key", false ) + /** Mirror bookmark status changes to logged in sync accounts (AniList, MAL, ...) */ + val syncBookmarks = preferences.getBoolean( + "sync_bookmarks_key", false + ) } class UIPreferences(preferences: PreferenceStore) {