Reconcile Trakt watchlist against the local watchlist by timestamp

Trakt's watchlist pull previously fed only a live, un-timestamped,
display-only union in HomeLibraryCoordinator/AndroidLibraryDataSource
(distinctBy id, recomputed on every emission, never persisted or
pushed back). This wires it into the Phase 0/1 groundwork instead:

- TraktSyncItem now parses listed_at, exposed via
  TraktSyncClient.getWatchlistWithListedAt / TraktRepository
- ExternalSyncMergeBridge wraps the mergeWatchlistTimestamped
  core_invoke call for Kotlin callers
- WatchlistManager gains a local membership snapshot (active entries +
  removal tombstones) and applyRemoteWatchlistAdd
- HomeLibraryCoordinator.load() now reconciles Trakt's remote
  watchlist against the local one after each fetch: items the merge
  says are locally newer get pushed via the existing
  ExternalSyncPushCoordinator.pushWatchlist, items remote is newer on
  get applied into the local watchlist_entries table

Simkl and Stremio are follow-up work — their fetch shapes differ
(Simkl has no true watchlist endpoint, Stremio's is a unified
datastore blob) and need separate integration passes.
This commit is contained in:
KhooLy 2026-07-18 19:16:22 +03:00
parent f11e9cee12
commit eaa863adde
8 changed files with 145 additions and 6 deletions

View file

@ -5,6 +5,8 @@ import com.fluxa.app.core.rust.FluxaUniFfiCoreStateHandle
import com.fluxa.app.data.local.*
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.repository.ExternalSyncMergeBridge
import com.fluxa.app.data.repository.ExternalSyncPushCoordinator
import com.fluxa.app.data.repository.StremioRepository
import com.fluxa.app.data.repository.TraktRepository
import com.google.gson.Gson
@ -35,6 +37,8 @@ data class LibraryUiState(
internal class HomeLibraryCoordinator(
private val repository: StremioRepository,
private val traktRepository: TraktRepository,
private val watchlistManager: WatchlistManager,
private val pushCoordinator: ExternalSyncPushCoordinator,
private val scope: CoroutineScope,
private val coreState: FluxaUniFfiCoreStateHandle,
private val gson: Gson
@ -71,7 +75,7 @@ internal class HomeLibraryCoordinator(
}
val traktToken = profile?.traktAccessToken
val traktPlanned = if (!traktToken.isNullOrBlank()) async(Dispatchers.IO) { traktRepository.getTraktWatchlist(traktToken) } else null
val traktPlannedWithListedAt = if (!traktToken.isNullOrBlank()) async(Dispatchers.IO) { traktRepository.getTraktWatchlistWithListedAt(traktToken) } else null
val traktWatched = if (!traktToken.isNullOrBlank()) async(Dispatchers.IO) { traktRepository.getTraktRecentlyWatched(traktToken, language, profile) } else null
val traktCollection = if (!traktToken.isNullOrBlank()) async(Dispatchers.IO) { traktRepository.getTraktCollection(traktToken) } else null
@ -85,9 +89,11 @@ internal class HomeLibraryCoordinator(
val simklPlanned = if (!simklToken.isNullOrBlank()) async(Dispatchers.IO) { repository.getSimklLibraryItems(simklToken, "plantowatch") } else null
val simklCompleted = if (!simklToken.isNullOrBlank()) async(Dispatchers.IO) { repository.getSimklLibraryItems(simklToken, "completed") } else null
val traktPlannedWithTimestamps = traktPlannedWithListedAt?.await().orEmpty()
setLibraryState(LibraryUiState(
continueItems = (stremioContinue.await() + externalContinue.await()).distinctBy { it.id },
traktPlanned = traktPlanned?.await().orEmpty(),
traktPlanned = traktPlannedWithTimestamps.map { it.first },
traktWatched = traktWatched?.await().orEmpty(),
traktCollection = traktCollection?.await().orEmpty(),
malWatching = malWatching?.await().orEmpty(),
@ -98,6 +104,13 @@ internal class HomeLibraryCoordinator(
simklCompleted = simklCompleted?.await().orEmpty(),
lastLoadedProfileKey = profileKey
))
if (profile != null && !traktToken.isNullOrBlank()) {
scope.launch(Dispatchers.IO) {
runCatching { reconcileTraktWatchlist(profile, traktPlannedWithTimestamps) }
.onFailure { Log.w("HomeLibrary", "Trakt watchlist reconcile failed", it) }
}
}
} catch (e: Exception) {
Log.w("HomeLibrary", "Failed to load library data", e)
setLibraryState(_state.value.copy(
@ -109,6 +122,27 @@ internal class HomeLibraryCoordinator(
}
}
private suspend fun reconcileTraktWatchlist(profile: UserProfile, remoteEntries: List<Pair<Meta, Long>>) {
val localSnapshot = watchlistManager.getWatchlistMembershipSnapshot()
val remoteMembership = remoteEntries.map { (meta, listedAtMs) ->
ExternalSyncMergeBridge.RemoteMembershipItem(meta.id, listedAtMs)
}
val plan = ExternalSyncMergeBridge.mergeWatchlist(localSnapshot, remoteMembership)
val remoteById = remoteEntries.associateBy { it.first.id }
plan.applyLocalAdd.forEach { id ->
remoteById[id]?.first?.let { watchlistManager.applyRemoteWatchlistAdd(it) }
}
plan.pushRemoteAdd.forEach { id ->
val meta = watchlistManager.getContentMeta(id) ?: return@forEach
pushCoordinator.pushWatchlist(profile, meta, isInWatchlist = true)
}
plan.pushRemoteRemove.forEach { id ->
val meta = watchlistManager.getContentMeta(id) ?: return@forEach
pushCoordinator.pushWatchlist(profile, meta, isInWatchlist = false)
}
}
private fun setLibraryState(value: LibraryUiState) {
val snapshotJson = coreState.dispatch(CoreAction(type = "setLibraryUiState", value = value))
val snapshot = gson.fromJson(snapshotJson, CoreStateSnapshot::class.java)?.library ?: return

View file

@ -53,6 +53,7 @@ class HomeViewModel @Inject constructor(
private val homeCategoryCache: HomeCategoryCache,
private val forgottenContinueWatchingStore: ForgottenContinueWatchingStore,
private val coordinatorFactory: HomeViewModelCoordinatorFactory,
private val externalSyncPushCoordinator: ExternalSyncPushCoordinator,
private val headlessEnvironment: FluxaAndroidHeadlessEnvironment,
private val nuvioSyncCoordinator: NuvioSyncCoordinator,
private val platformContentGateway: HomePlatformContentGateway,
@ -214,7 +215,7 @@ class HomeViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L)
private val libraryCoordinator by lazy {
coordinatorFactory.library(repository, traktRepository, viewModelScope, coreState, gson)
coordinatorFactory.library(repository, traktRepository, watchlistManager, externalSyncPushCoordinator, viewModelScope, coreState, gson)
}
val libraryUiState: StateFlow<LibraryUiState> get() = libraryCoordinator.state

View file

@ -6,6 +6,7 @@ import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.local.WatchlistManager
import com.fluxa.app.data.remote.AddonDescriptor
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.repository.ExternalSyncPushCoordinator
import com.fluxa.app.data.repository.StremioRepository
import com.fluxa.app.data.repository.TraktRepository
import com.fluxa.app.data.repository.TraktWatchedState
@ -18,11 +19,13 @@ class HomeViewModelCoordinatorFactory @Inject constructor() {
internal fun library(
repository: StremioRepository,
traktRepository: TraktRepository,
watchlistManager: WatchlistManager,
pushCoordinator: ExternalSyncPushCoordinator,
scope: CoroutineScope,
coreState: FluxaUniFfiCoreStateHandle,
gson: Gson
): HomeLibraryCoordinator {
return HomeLibraryCoordinator(repository, traktRepository, scope, coreState, gson)
return HomeLibraryCoordinator(repository, traktRepository, watchlistManager, pushCoordinator, scope, coreState, gson)
}
internal fun playback(

View file

@ -105,6 +105,26 @@ class WatchlistManager @Inject constructor(
return dao.isInWatchlist(pid(), id)
}
data class WatchlistMembershipEntry(val id: String, val active: Boolean, val updatedAt: Long)
suspend fun getWatchlistMembershipSnapshot(): List<WatchlistMembershipEntry> {
val profileId = pid()
val active = dao.getWatchlistEntries(profileId).map { WatchlistMembershipEntry(it.contentId, true, it.updatedAt) }
val removed = dao.getWatchlistRemovals(profileId).map { WatchlistMembershipEntry(it.contentId, false, it.removedAt) }
return active + removed
}
suspend fun applyRemoteWatchlistAdd(item: Meta) {
val profileId = pid()
dao.upsertContent(item.toContentItemEntity(profileId))
dao.clearWatchlistRemoval(profileId, item.id)
dao.upsertWatchlistEntry(WatchlistEntryEntity(profileId, item.id))
}
suspend fun getContentMeta(id: String): Meta? {
return dao.getContentState(pid(), id)?.toMeta()
}
suspend fun setFeedback(id: String, isLike: Boolean?, metaIfNew: Meta? = null) {
val profileId = pid()
metaIfNew?.let { dao.upsertContent(it.toContentItemEntity(profileId)) }

View file

@ -0,0 +1,55 @@
package com.fluxa.app.data.repository
import com.fluxa.app.core.rust.FluxaCoreUniFfi
import com.fluxa.app.data.local.WatchlistManager.WatchlistMembershipEntry
import com.google.gson.JsonArray
import com.google.gson.JsonObject
object ExternalSyncMergeBridge {
data class MembershipMergePlan(
val applyLocalAdd: List<String>,
val pushRemoteAdd: List<String>,
val pushRemoteRemove: List<String>
)
data class RemoteMembershipItem(val id: String, val updatedAt: Long)
fun mergeWatchlist(local: List<WatchlistMembershipEntry>, remote: List<RemoteMembershipItem>): MembershipMergePlan =
merge("mergeWatchlistTimestamped", local, remote)
private fun merge(
method: String,
local: List<WatchlistMembershipEntry>,
remote: List<RemoteMembershipItem>
): MembershipMergePlan {
val localJson = JsonArray().apply {
local.forEach { entry ->
add(JsonObject().apply {
addProperty("id", entry.id)
addProperty("active", entry.active)
addProperty("updatedAt", entry.updatedAt)
})
}
}
val remoteJson = JsonArray().apply {
remote.forEach { entry ->
add(JsonObject().apply {
addProperty("id", entry.id)
addProperty("updatedAt", entry.updatedAt)
})
}
}
val args = JsonObject().apply {
add("local", localJson)
add("remote", remoteJson)
}
val result = FluxaCoreUniFfi.coreInvokeValue(method, args.toString()).asJsonObject
val apply = result.getAsJsonObject("toApplyLocal")
val push = result.getAsJsonObject("toPushRemote")
return MembershipMergePlan(
applyLocalAdd = apply.getAsJsonArray("add").map { it.asString },
pushRemoteAdd = push.getAsJsonArray("add").map { it.asString },
pushRemoteRemove = push.getAsJsonArray("remove").map { it.asString }
)
}
}

View file

@ -45,6 +45,10 @@ class TraktRepository @Inject constructor(
traktSyncClient.getWatchlist(token)
}
suspend fun getWatchlistWithListedAt(token: String): List<Pair<Meta, Long>> = withContext(Dispatchers.IO) {
traktSyncClient.getWatchlistWithListedAt(token)
}
suspend fun getRecentlyWatched(token: String, language: String = "en", profile: UserProfile? = null): List<Meta> = withContext(Dispatchers.IO) {
if (!TraktIntegration.hasClient(TRAKT_KEY)) return@withContext emptyList()
try {
@ -153,6 +157,8 @@ class TraktRepository @Inject constructor(
suspend fun getTraktWatchlist(token: String): List<Meta> = getWatchlist(token)
suspend fun getTraktWatchlistWithListedAt(token: String): List<Pair<Meta, Long>> = getWatchlistWithListedAt(token)
suspend fun getTraktRecentlyWatched(token: String, language: String = "en", profile: UserProfile? = null): List<Meta> =
getRecentlyWatched(token, language, profile)

View file

@ -37,6 +37,23 @@ class TraktSyncClient @Inject constructor(
}
}
suspend fun getWatchlistWithListedAt(token: String): List<Pair<Meta, Long>> = withContext(Dispatchers.IO) {
if (!TraktIntegration.hasClient(traktKey)) return@withContext emptyList()
try {
val auth = TraktIntegration.bearer(token)
fetchTraktSyncPages { page, limit -> traktApi.getWatchlist(auth, traktKey, page, limit) }
.mapNotNull { item ->
val listedAtMs = item.listedAt?.let { runCatching { java.time.Instant.parse(it).toEpochMilli() }.getOrNull() }
?: return@mapNotNull null
val type = if (item.movie != null) "movie" else "series"
val meta = item.toMeta(type) { AppStrings.t(null, "auto.unknown") } ?: return@mapNotNull null
meta to listedAtMs
}
} catch (e: Exception) {
emptyList()
}
}
suspend fun getRecentlyWatched(token: String): List<Meta> = withContext(Dispatchers.IO) {
getRecentlyWatchedResult(token).getOrReport(emptyList())
}

View file

@ -35,8 +35,11 @@ data class TraktSyncItem(
val id: Long? = null,
val movie: TraktSummary? = null,
val show: TraktSummary? = null,
val seasons: List<TraktWatchedSeason>? = null
)
val seasons: List<TraktWatchedSeason>? = null,
val listed_at: String? = null
) {
val listedAt: String? get() = listed_at
}
@Serializable
data class TraktWatchedSeason(