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/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 17a698de2df..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 @@ -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 @@ -137,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 @@ -802,6 +804,7 @@ class ResultViewModel2 : ViewModel() { if (currentStatus != status) { MainActivity.bookmarksUpdatedEvent(true) MainActivity.reloadLibraryEvent(true) + syncWatchStatus(response.syncData, status) } _watchStatus.postValue(status) @@ -810,6 +813,33 @@ class ResultViewModel2 : ViewModel() { } } + /** + * Mirrors a bookmark status change to every logged in sync account (AniList, MAL, ...) + * 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) + + 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, 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/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()) + } +} 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) {