mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 23:12:24 +00:00
Merge branch 'trakttokensync' into cmp-rewrite
This commit is contained in:
commit
2dbaeb5942
3 changed files with 204 additions and 2 deletions
|
|
@ -10,6 +10,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
|
|||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.plugins.PluginRepository
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.trakt.TraktCredentialSync
|
||||
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
|
|
@ -49,6 +50,10 @@ object SyncManager {
|
|||
.onFailure { log.e(it) { "Plugin pull failed" } }
|
||||
}
|
||||
|
||||
runCatching { TraktCredentialSync.pullFromRemote(profileId) }
|
||||
.onSuccess { applied -> log.i { "pullAllForProfile — Trakt credential pull completed applied=$applied" } }
|
||||
.onFailure { log.e(it) { "Trakt credential pull failed" } }
|
||||
|
||||
log.i { "pullAllForProfile — launching remaining pulls in parallel" }
|
||||
launch {
|
||||
runCatching { LibraryRepository.pullFromServer(profileId) }
|
||||
|
|
@ -153,6 +158,9 @@ object SyncManager {
|
|||
scope.launch {
|
||||
log.i { "pullForegroundForProfile($profileId) — syncing watch progress, library, collections, and home settings" }
|
||||
|
||||
runCatching { TraktCredentialSync.pullFromRemote(profileId) }
|
||||
.onFailure { log.e(it) { "Foreground Trakt credential pull failed" } }
|
||||
|
||||
launch {
|
||||
runCatching { LibraryRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Foreground library pull failed" } }
|
||||
|
|
|
|||
|
|
@ -65,6 +65,24 @@ object TraktAuthRepository {
|
|||
return _uiState.value
|
||||
}
|
||||
|
||||
internal fun currentStateForSync(): TraktAuthState {
|
||||
ensureLoaded()
|
||||
return authState
|
||||
}
|
||||
|
||||
internal fun replaceStateFromSync(state: TraktAuthState): Boolean {
|
||||
ensureLoaded()
|
||||
val syncedState = state.copy(
|
||||
pendingAuthorizationState = null,
|
||||
pendingAuthorizationStartedAtMillis = null,
|
||||
)
|
||||
if (authState.syncSignature() == syncedState.syncSignature()) return false
|
||||
authState = syncedState
|
||||
persist()
|
||||
publish(statusMessage = null, errorMessage = null)
|
||||
return true
|
||||
}
|
||||
|
||||
fun hasRequiredCredentials(): Boolean =
|
||||
TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank()
|
||||
|
||||
|
|
@ -281,6 +299,7 @@ object TraktAuthRepository {
|
|||
)
|
||||
persist()
|
||||
refreshUserSettings()
|
||||
TraktCredentialSync.pushCurrentToRemote()
|
||||
publish(
|
||||
isLoading = false,
|
||||
statusMessage = localizedString(Res.string.trakt_connected_status),
|
||||
|
|
@ -312,6 +331,7 @@ object TraktAuthRepository {
|
|||
}
|
||||
}
|
||||
|
||||
TraktCredentialSync.deleteRemote()
|
||||
authState = TraktAuthState()
|
||||
persist()
|
||||
publish(
|
||||
|
|
@ -347,11 +367,21 @@ object TraktAuthRepository {
|
|||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
log.w { "Trakt token refresh failed: ${error.message}" }
|
||||
}.getOrNull() ?: return false
|
||||
}.getOrNull()
|
||||
|
||||
if (response == null) {
|
||||
if (recoverFromRemoteCredentials(refreshToken)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
val parsed = runCatching {
|
||||
json.decodeFromString<TraktTokenResponse>(response)
|
||||
}.getOrNull() ?: return false
|
||||
}.getOrNull()
|
||||
|
||||
if (parsed == null) {
|
||||
if (recoverFromRemoteCredentials(refreshToken)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
authState = authState.copy(
|
||||
accessToken = parsed.accessToken,
|
||||
|
|
@ -361,6 +391,7 @@ object TraktAuthRepository {
|
|||
expiresIn = parsed.expiresIn,
|
||||
)
|
||||
persist()
|
||||
TraktCredentialSync.pushCurrentToRemote()
|
||||
publish()
|
||||
return true
|
||||
}
|
||||
|
|
@ -443,6 +474,23 @@ object TraktAuthRepository {
|
|||
val nowSeconds = TraktPlatformClock.nowEpochMs() / 1_000L
|
||||
return nowSeconds >= (expiresAtSeconds - 60)
|
||||
}
|
||||
|
||||
private suspend fun recoverFromRemoteCredentials(staleRefreshToken: String): Boolean {
|
||||
val pulled = TraktCredentialSync.pullFromRemote()
|
||||
if (!pulled) return false
|
||||
return authState.isAuthenticated && authState.refreshToken != staleRefreshToken
|
||||
}
|
||||
|
||||
private fun TraktAuthState.syncSignature(): String =
|
||||
listOf(
|
||||
accessToken.orEmpty(),
|
||||
refreshToken.orEmpty(),
|
||||
tokenType.orEmpty(),
|
||||
createdAt?.toString().orEmpty(),
|
||||
expiresIn?.toString().orEmpty(),
|
||||
username.orEmpty(),
|
||||
userSlug.orEmpty(),
|
||||
).joinToString("|")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.core.sync.putSyncOriginClientId
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.rpc
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private const val TRAKT_PROVIDER = "trakt"
|
||||
private const val TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS = 86_400
|
||||
|
||||
@Serializable
|
||||
private data class ProviderCredentialRow(
|
||||
val provider: String,
|
||||
@SerialName("credential_json") val credentialJson: JsonObject,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
|
||||
object TraktCredentialSync {
|
||||
private val log = Logger.withTag("TraktCredentialSync")
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun pushCurrentToRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean =
|
||||
mutex.withLock {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false
|
||||
|
||||
val state = TraktAuthRepository.currentStateForSync()
|
||||
val credentialJson = state.toCredentialJson() ?: return@withLock false
|
||||
runCatching {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_credentials", buildJsonArray {
|
||||
addJsonObject {
|
||||
put("provider", TRAKT_PROVIDER)
|
||||
put("credential_json", credentialJson)
|
||||
}
|
||||
})
|
||||
putSyncOriginClientId()
|
||||
}
|
||||
SupabaseProvider.client.postgrest.rpc("sync_push_provider_credentials", params)
|
||||
true
|
||||
}.getOrElse { error ->
|
||||
log.e(error) { "pushCurrentToRemote(profileId=$profileId) failed" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pullFromRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean =
|
||||
mutex.withLock {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false
|
||||
|
||||
runCatching {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_provider_credentials", params)
|
||||
val rows = result.decodeList<ProviderCredentialRow>()
|
||||
val row = rows.firstOrNull { it.provider.equals(TRAKT_PROVIDER, ignoreCase = true) }
|
||||
?: return@runCatching false
|
||||
val remoteState = row.credentialJson.toTraktAuthState() ?: return@runCatching false
|
||||
TraktAuthRepository.replaceStateFromSync(remoteState)
|
||||
}.getOrElse { error ->
|
||||
log.e(error) { "pullFromRemote(profileId=$profileId) failed" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean =
|
||||
mutex.withLock {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false
|
||||
|
||||
runCatching {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_provider", TRAKT_PROVIDER)
|
||||
putSyncOriginClientId()
|
||||
}
|
||||
SupabaseProvider.client.postgrest.rpc("sync_delete_provider_credentials", params)
|
||||
true
|
||||
}.getOrElse { error ->
|
||||
log.e(error) { "deleteRemote(profileId=$profileId) failed" }
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TraktAuthState.toCredentialJson(): JsonObject? {
|
||||
val accessTokenValue = accessToken?.takeIf { it.isNotBlank() } ?: return null
|
||||
val refreshTokenValue = refreshToken?.takeIf { it.isNotBlank() } ?: return null
|
||||
return buildJsonObject {
|
||||
put("access_token", accessTokenValue)
|
||||
put("refresh_token", refreshTokenValue)
|
||||
put("token_type", tokenType ?: "bearer")
|
||||
put("created_at", createdAt ?: (TraktPlatformClock.nowEpochMs() / 1_000L))
|
||||
put("expires_in", normalizeTraktTokenLifetime(expiresIn ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS))
|
||||
username?.takeIf { it.isNotBlank() }?.let { put("username", it) }
|
||||
userSlug?.takeIf { it.isNotBlank() }?.let { put("user_slug", it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.toTraktAuthState(): TraktAuthState? {
|
||||
val accessTokenValue = stringValue("access_token")?.takeIf { it.isNotBlank() } ?: return null
|
||||
val refreshTokenValue = stringValue("refresh_token")?.takeIf { it.isNotBlank() } ?: return null
|
||||
return TraktAuthState(
|
||||
accessToken = accessTokenValue,
|
||||
refreshToken = refreshTokenValue,
|
||||
tokenType = stringValue("token_type") ?: "bearer",
|
||||
createdAt = longValue("created_at") ?: (TraktPlatformClock.nowEpochMs() / 1_000L),
|
||||
expiresIn = normalizeTraktTokenLifetime(intValue("expires_in") ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS),
|
||||
username = stringValue("username"),
|
||||
userSlug = stringValue("user_slug"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeTraktTokenLifetime(expiresIn: Int): Int {
|
||||
if (expiresIn <= 0) return TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS
|
||||
return expiresIn.coerceAtMost(TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS)
|
||||
}
|
||||
|
||||
private fun JsonObject.stringValue(key: String): String? =
|
||||
this[key]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
private fun JsonObject.longValue(key: String): Long? =
|
||||
this[key]?.jsonPrimitive?.longOrNull
|
||||
|
||||
private fun JsonObject.intValue(key: String): Int? =
|
||||
this[key]?.jsonPrimitive?.intOrNull
|
||||
Loading…
Reference in a new issue