Pull Stremio's library as a timestamped watchlist and reconcile it

LibraryItem never declared removed/_mtime so those fields were
silently dropped by Gson before Rust ever saw them, even though
library_state.rs already worked on raw JSON and could have used them.
Adds the fields, StremioRepository.getLibraryWatchlistWithTimestamps,
and wires it into the same mergeWatchlist reconciliation the other
providers use -- but pull-only (reconcileWatchlistPullOnly), not the
full push-capable path.

Deliberately not pushing local watchlist changes to Stremio: that
would need a "toggle membership" write that preserves whatever
progress/state fields already exist on the remote item (a
read-modify-write), which isn't built and isn't safe to guess at
without being able to verify Stremio's actual datastore PUT semantics
(full-replace vs partial-patch) against a live account.
This commit is contained in:
KhooLy 2026-07-18 20:14:09 +03:00
parent fea466cf27
commit 1039bfa5d3
4 changed files with 55 additions and 1 deletions

View file

@ -75,6 +75,10 @@ internal class HomeLibraryCoordinator(
.getOrDefault(emptyList())
} ?: emptyList()
}
val stremioAuthKey = profile?.authKey?.takeIf { !profile.isGuest }
val stremioWatchlistWithTimestamps = stremioAuthKey?.let { authKey ->
async(Dispatchers.IO) { repository.getLibraryWatchlistWithTimestamps(authKey) }
}
val traktToken = profile?.traktAccessToken
val traktPlannedWithListedAt = if (!traktToken.isNullOrBlank()) async(Dispatchers.IO) { traktRepository.getTraktWatchlistWithListedAt(traktToken) } else null
@ -142,6 +146,13 @@ internal class HomeLibraryCoordinator(
.onFailure { Log.w("HomeLibrary", "AniList watchlist reconcile failed", it) }
}
}
if (stremioAuthKey != null) {
scope.launch(Dispatchers.IO) {
val remoteWatchlist = stremioWatchlistWithTimestamps?.await().orEmpty()
runCatching { reconcileWatchlistPullOnly(remoteWatchlist) }
.onFailure { Log.w("HomeLibrary", "Stremio watchlist reconcile failed", it) }
}
}
} catch (e: Exception) {
Log.w("HomeLibrary", "Failed to load library data", e)
setLibraryState(_state.value.copy(
@ -174,6 +185,18 @@ internal class HomeLibraryCoordinator(
}
}
private suspend fun reconcileWatchlistPullOnly(remoteEntries: List<Pair<Meta, Long>>) {
val localSnapshot = watchlistManager.getWatchlistMembershipSnapshot()
val remoteMembership = remoteEntries.map { (meta, updatedAtMs) ->
ExternalSyncMergeBridge.RemoteMembershipItem(meta.id, updatedAtMs)
}
val plan = ExternalSyncMergeBridge.mergeWatchlist(localSnapshot, remoteMembership)
val remoteById = remoteEntries.associateBy { it.first.id }
plan.applyLocalAdd.forEach { id ->
remoteById[id]?.first?.let { watchlistManager.applyRemoteWatchlistAdd(it) }
}
}
private suspend fun reconcileWatchedEpisodes(profile: UserProfile, remoteWatched: Map<String, Long>) {
val localSnapshot = watchlistManager.getWatchedEpisodeMembershipSnapshot()
val remoteMembership = remoteWatched.map { (videoId, watchedAtMs) ->

View file

@ -1781,6 +1781,10 @@ object FluxaCoreNative {
return value.takeUnless { it.isJsonNull }?.let { gson.fromJson<List<Meta>>(it, metaListType) } ?: emptyList()
}
fun libraryWatchlistItems(items: List<LibraryItem>): com.google.gson.JsonElement {
return FluxaCoreUniFfi.coreInvokeValue("libraryWatchlistItems", gson.toJson(items))
}
fun filterHomeContinueWatching(items: List<Meta>, traktWatchedState: TraktWatchedState): List<Meta> {
val args = JsonObject().apply {
addProperty("itemsJson", gson.toJson(items))

View file

@ -211,6 +211,31 @@ class StremioRepository @Inject constructor(
}
}
suspend fun getLibraryWatchlistWithTimestamps(authKey: String): List<Pair<Meta, Long>> = withContext(Dispatchers.IO) {
try {
val response = authService.getDatastore(DatastoreRequest(authKey, "library"))
val value = FluxaCoreNative.libraryWatchlistItems(response.body()?.result.orEmpty())
if (value.isJsonNull) return@withContext emptyList()
value.asJsonArray.mapNotNull { entry ->
val item = entry.asJsonObject
val id = item.get("id")?.takeUnless { it.isJsonNull }?.asString ?: return@mapNotNull null
val updatedAtMs = item.get("updatedAtMs")?.takeUnless { it.isJsonNull }?.asLong ?: return@mapNotNull null
val meta = Meta(
id = id,
name = item.get("name")?.takeUnless { it.isJsonNull }?.asString ?: id,
type = item.get("type")?.takeUnless { it.isJsonNull }?.asString ?: "",
poster = item.get("poster")?.takeUnless { it.isJsonNull }?.asString,
background = item.get("background")?.takeUnless { it.isJsonNull }?.asString,
reason = "Stremio"
)
meta to updatedAtMs
}
} catch (e: Exception) {
failureReporter.report("stremio.library.getWatchlist", e)
emptyList()
}
}
suspend fun getWatchedVideoIds(authKey: String, imdbId: String): List<String> = withContext(Dispatchers.IO) {
try {
val response = authService.getDatastore(DatastoreRequest(authKey, "library"))

View file

@ -20,7 +20,9 @@ data class LibraryItem(
val background: String? = null,
val logo: String? = null,
val state: LibraryItemState? = null,
val lastWatched: String? = null
val lastWatched: String? = null,
val removed: Boolean? = null,
val _mtime: String? = null
) {
val id: String get() = _id
}