Pull AniList's watchlist (PLANNING status) and reconcile it by timestamp

The AniList GraphQL query only ever fetched status: CURRENT, so the
watchlist-add push added in the AniList phase had nothing to
reconcile against on the next sync -- pushed items would just get
re-pushed or silently diverge. Drops the CURRENT-only filter (Rust's
anilist_entries_to_sync already builds a timestamped watchlist array
from PLANNING entries, just needed the query to actually fetch them)
and wires it through the same mergeWatchlist reconciliation Trakt
uses. Generalizes reconcileTraktWatchlist -> reconcileWatchlist since
the logic is fully provider-agnostic.
This commit is contained in:
KhooLy 2026-07-18 20:04:22 +03:00
parent 32e7774056
commit edc69261e0
3 changed files with 50 additions and 14 deletions

View file

@ -93,6 +93,9 @@ internal class HomeLibraryCoordinator(
val simklCompleted = if (!simklToken.isNullOrBlank()) async(Dispatchers.IO) { repository.getSimklLibraryItems(simklToken, "completed") } else null
val simklWatchedEpisodesWithTimestamps = if (!simklToken.isNullOrBlank()) async(Dispatchers.IO) { repository.getSimklWatchedEpisodesWithTimestamps(simklToken) } else null
val anilistToken = profile?.anilistAccessToken
val anilistWatchlistWithTimestamps = if (!anilistToken.isNullOrBlank()) async(Dispatchers.IO) { repository.getAnilistWatchlistWithTimestamps(anilistToken) } else null
val traktPlannedWithTimestamps = traktPlannedWithListedAt?.await().orEmpty()
setLibraryState(LibraryUiState(
@ -111,7 +114,7 @@ internal class HomeLibraryCoordinator(
if (profile != null && !traktToken.isNullOrBlank()) {
scope.launch(Dispatchers.IO) {
runCatching { reconcileTraktWatchlist(profile, traktPlannedWithTimestamps) }
runCatching { reconcileWatchlist(profile, traktPlannedWithTimestamps) }
.onFailure { Log.w("HomeLibrary", "Trakt watchlist reconcile failed", it) }
}
scope.launch(Dispatchers.IO) {
@ -132,6 +135,13 @@ internal class HomeLibraryCoordinator(
.onFailure { Log.w("HomeLibrary", "Simkl watched reconcile failed", it) }
}
}
if (profile != null && !anilistToken.isNullOrBlank()) {
scope.launch(Dispatchers.IO) {
val remotePlanned = anilistWatchlistWithTimestamps?.await().orEmpty()
runCatching { reconcileWatchlist(profile, remotePlanned) }
.onFailure { Log.w("HomeLibrary", "AniList watchlist reconcile failed", it) }
}
}
} catch (e: Exception) {
Log.w("HomeLibrary", "Failed to load library data", e)
setLibraryState(_state.value.copy(
@ -143,7 +153,7 @@ internal class HomeLibraryCoordinator(
}
}
private suspend fun reconcileTraktWatchlist(profile: UserProfile, remoteEntries: List<Pair<Meta, Long>>) {
private suspend fun reconcileWatchlist(profile: UserProfile, remoteEntries: List<Pair<Meta, Long>>) {
val localSnapshot = watchlistManager.getWatchlistMembershipSnapshot()
val remoteMembership = remoteEntries.map { (meta, listedAtMs) ->
ExternalSyncMergeBridge.RemoteMembershipItem(meta.id, listedAtMs)

View file

@ -28,9 +28,9 @@ import javax.inject.Singleton
private const val EPISODE_PROGRESS_UNIT_MS = 45 * 60_000L
private const val ANILIST_CURRENT_LIST_QUERY = """
private const val ANILIST_LIST_QUERY = """
query (${'$'}userId: Int) {
MediaListCollection(userId: ${'$'}userId, type: ANIME, status: CURRENT) {
MediaListCollection(userId: ${'$'}userId, type: ANIME) {
lists {
entries {
status
@ -322,8 +322,8 @@ class ExternalLibraryClient @Inject constructor(
)
}
private suspend fun getAnilistContinueWatchingItems(token: String?): List<Meta> {
if (token.isNullOrBlank()) return emptyList()
private suspend fun anilistSyncValue(token: String?): com.google.gson.JsonObject? {
if (token.isNullOrBlank()) return null
val authorization = "Bearer $token"
return try {
val viewerResponse = traktApi.anilistGraphQl(
@ -335,12 +335,12 @@ class ExternalLibraryClient @Inject constructor(
?.getAsJsonObject("Viewer")
?.get("id")
?.takeUnless { it.isJsonNull }
?.asInt ?: return emptyList()
?.asInt ?: return null
val listResponse = traktApi.anilistGraphQl(
authorization,
AnilistGraphQlRequest(
query = ANILIST_CURRENT_LIST_QUERY,
query = ANILIST_LIST_QUERY,
variables = mapOf("userId" to viewerId)
)
)
@ -353,7 +353,7 @@ class ExternalLibraryClient @Inject constructor(
lists.forEach { list ->
list.asJsonObject.getAsJsonArray("entries")?.forEach(entries::add)
}
if (entries.size() == 0) return emptyList()
if (entries.size() == 0) return null
val args = com.google.gson.JsonObject().apply {
add("entries", entries)
@ -362,15 +362,38 @@ class ExternalLibraryClient @Inject constructor(
val envelope = JsonParser.parseString(
FluxaCoreUniFfi.coreInvoke("anilistEntriesToSync", args.toString())
).asJsonObject
if (envelope.get("ok")?.asBoolean != true) return emptyList()
val progress = envelope.getAsJsonObject("value")?.getAsJsonObject("progress") ?: return emptyList()
progress.entrySet().mapNotNull { (_, value) -> anilistProgressEntryToMeta(value.asJsonObject) }
if (envelope.get("ok")?.asBoolean != true) return null
envelope.getAsJsonObject("value")
} catch (e: Exception) {
Log.w("ExternalLibraryClient", "Failed to load AniList continue watching items", e)
emptyList()
Log.w("ExternalLibraryClient", "Failed to load AniList sync data", e)
null
}
}
private suspend fun getAnilistContinueWatchingItems(token: String?): List<Meta> {
val progress = anilistSyncValue(token)?.getAsJsonObject("progress") ?: return emptyList()
return progress.entrySet().mapNotNull { (_, value) -> anilistProgressEntryToMeta(value.asJsonObject) }
}
suspend fun getAnilistWatchlistWithTimestamps(token: String?): List<Pair<Meta, Long>> {
val watchlist = anilistSyncValue(token)?.getAsJsonArray("watchlist") ?: return emptyList()
return watchlist.mapNotNull { entry -> anilistWatchlistEntryToMeta(entry.asJsonObject) }
}
private fun anilistWatchlistEntryToMeta(item: com.google.gson.JsonObject): Pair<Meta, Long>? {
val id = item.get("id")?.takeUnless { it.isJsonNull }?.asString ?: return null
val updatedAtMs = item.get("updatedAtMs")?.takeUnless { it.isJsonNull }?.asLong ?: return null
val meta = Meta(
id = id,
name = item.get("name")?.takeUnless { it.isJsonNull }?.asString ?: id,
type = item.get("type")?.takeUnless { it.isJsonNull }?.asString ?: "series",
poster = item.get("poster")?.takeUnless { it.isJsonNull }?.asString,
background = item.get("background")?.takeUnless { it.isJsonNull }?.asString,
reason = "AniList"
)
return meta to updatedAtMs
}
private fun anilistProgressEntryToMeta(entry: com.google.gson.JsonObject): Meta? {
val meta = entry.getAsJsonObject("meta") ?: return null
val id = meta.get("id")?.takeUnless { it.isJsonNull }?.asString ?: return null

View file

@ -195,6 +195,9 @@ class StremioRepository @Inject constructor(
suspend fun getSimklWatchedEpisodesWithTimestamps(token: String?): Map<String, Long> =
externalLibraryClient.getSimklWatchedEpisodesWithTimestamps(token)
suspend fun getAnilistWatchlistWithTimestamps(token: String?): List<Pair<Meta, Long>> =
externalLibraryClient.getAnilistWatchlistWithTimestamps(token)
suspend fun clearTraktPlaybackProgress(token: String?, meta: Meta) =
traktRepository.clearPlaybackProgress(token, meta)