mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-18 13:05:56 +00:00
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:
parent
f11e9cee12
commit
eaa863adde
8 changed files with 145 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue