diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeLibraryCoordinator.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeLibraryCoordinator.kt index 1321a9c..418f1a1 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeLibraryCoordinator.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeLibraryCoordinator.kt @@ -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>) { + private suspend fun reconcileWatchlist(profile: UserProfile, remoteEntries: List>) { val localSnapshot = watchlistManager.getWatchlistMembershipSnapshot() val remoteMembership = remoteEntries.map { (meta, listedAtMs) -> ExternalSyncMergeBridge.RemoteMembershipItem(meta.id, listedAtMs) diff --git a/data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalLibraryClient.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalLibraryClient.kt index b4b8275..b6c45f5 100644 --- a/data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalLibraryClient.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/ExternalLibraryClient.kt @@ -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 { - 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 { + val progress = anilistSyncValue(token)?.getAsJsonObject("progress") ?: return emptyList() + return progress.entrySet().mapNotNull { (_, value) -> anilistProgressEntryToMeta(value.asJsonObject) } + } + + suspend fun getAnilistWatchlistWithTimestamps(token: String?): List> { + val watchlist = anilistSyncValue(token)?.getAsJsonArray("watchlist") ?: return emptyList() + return watchlist.mapNotNull { entry -> anilistWatchlistEntryToMeta(entry.asJsonObject) } + } + + private fun anilistWatchlistEntryToMeta(item: com.google.gson.JsonObject): Pair? { + 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 diff --git a/data/src/androidMain/kotlin/com/fluxa/app/data/repository/StremioRepository.kt b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/StremioRepository.kt index e998c7a..4625e6a 100644 --- a/data/src/androidMain/kotlin/com/fluxa/app/data/repository/StremioRepository.kt +++ b/data/src/androidMain/kotlin/com/fluxa/app/data/repository/StremioRepository.kt @@ -195,6 +195,9 @@ class StremioRepository @Inject constructor( suspend fun getSimklWatchedEpisodesWithTimestamps(token: String?): Map = externalLibraryClient.getSimklWatchedEpisodesWithTimestamps(token) + suspend fun getAnilistWatchlistWithTimestamps(token: String?): List> = + externalLibraryClient.getAnilistWatchlistWithTimestamps(token) + suspend fun clearTraktPlaybackProgress(token: String?, meta: Meta) = traktRepository.clearPlaybackProgress(token, meta)