Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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
Expand All @@ -808,9 +817,25 @@ class AniListApi : SyncAPI() {
}

val data = postApi(auth.token, q)
return data != ""
if (data.isNullOrBlank()) return false
val errors = tryParseJson<GraphQLErrorRoot>(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<GraphQLError>?,
)

private suspend fun getUser(token: AuthToken): AniListUser? {
val q = """
{
Expand Down Expand Up @@ -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?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -802,6 +804,7 @@ class ResultViewModel2 : ViewModel() {
if (currentStatus != status) {
MainActivity.bookmarksUpdatedEvent(true)
MainActivity.reloadLibraryEvent(true)
syncWatchStatus(response.syncData, status)
}

_watchStatus.postValue(status)
Expand All @@ -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<String, String>?, 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,8 @@
<string name="edit_account">Edit account</string>
<string name="logged_account" formatted="true">Logged in as %s</string>
<string name="skip_startup_account_select_pref">Skip account selection at startup</string>
<string name="sync_bookmarks_pref">Sync bookmarks to accounts</string>
<string name="sync_bookmarks_pref_summary">Changing a bookmark also updates the title on your AniList, MAL and other logged in lists</string>
<string name="use_default_account">Use Default Account</string>
<string name="rotate_video">Rotate</string>
<string name="rotate_video_desc">Display a toggle button for screen orientation</string>
Expand Down
67 changes: 67 additions & 0 deletions app/src/test/java/com/lagradost/cloudstream3/AniListParsingTest.kt
Original file line number Diff line number Diff line change
@@ -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<AniListApi.FullAnilistList>(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<AniListApi.GraphQLErrorRoot>(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<AniListApi.GraphQLErrorRoot>(success).errors.isNullOrEmpty())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading