mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 14:32:28 +00:00
fix(trakt): invalidate rejected refresh tokens
This commit is contained in:
parent
c68d996881
commit
0cb0e20918
6 changed files with 102 additions and 208 deletions
|
|
@ -1468,6 +1468,7 @@
|
|||
<string name="stream_default_name">Stream</string>
|
||||
<string name="source_embedded">Embedded</string>
|
||||
<string name="trakt_authorization_denied">Authorization denied</string>
|
||||
<string name="trakt_authorization_expired_reconnect">Your Trakt authorization is no longer valid. Reconnect your Trakt account.</string>
|
||||
<string name="trakt_complete_sign_in_browser">Complete Trakt sign in in your browser</string>
|
||||
<string name="trakt_connected">Connected to Trakt</string>
|
||||
<string name="trakt_disconnected">Disconnected from Trakt</string>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ 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.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktCredentialSync
|
||||
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode
|
||||
|
|
@ -39,7 +38,6 @@ internal enum class ProfileSyncStep {
|
|||
Addons,
|
||||
Plugins,
|
||||
ProfileSettings,
|
||||
TraktCredentials,
|
||||
Library,
|
||||
ActiveWatchSource,
|
||||
Collections,
|
||||
|
|
@ -50,7 +48,6 @@ internal data class ProfileSyncOperations(
|
|||
val pullAddons: suspend (Int) -> Unit,
|
||||
val pullPlugins: suspend (Int) -> Unit,
|
||||
val pullProfileSettings: suspend (Int) -> Unit,
|
||||
val pullTraktCredentials: suspend (Int) -> Unit,
|
||||
val pullLibrary: suspend (Int) -> Unit,
|
||||
val refreshActiveWatchSource: suspend (Int) -> Unit,
|
||||
val pullCollections: suspend (Int) -> Unit,
|
||||
|
|
@ -94,16 +91,7 @@ internal suspend fun runOrderedProfileSync(
|
|||
runStep(ProfileSyncStep.Plugins, operations.pullPlugins)
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
val settingsJob = launch {
|
||||
runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings)
|
||||
}
|
||||
val credentialsJob = launch {
|
||||
runStep(ProfileSyncStep.TraktCredentials, operations.pullTraktCredentials)
|
||||
}
|
||||
settingsJob.join()
|
||||
credentialsJob.join()
|
||||
}
|
||||
runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings)
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
|
|
@ -229,7 +217,6 @@ object SyncManager {
|
|||
pullAddons = { profileId -> AddonRepository.pullFromServer(profileId) },
|
||||
pullPlugins = { profileId -> PluginRepository.pullFromServer(profileId) },
|
||||
pullProfileSettings = { profileId -> ProfileSettingsSync.pull(profileId) },
|
||||
pullTraktCredentials = { profileId -> TraktCredentialSync.pullFromRemoteOrThrow(profileId) },
|
||||
pullLibrary = { profileId -> LibraryRepository.pullFromServer(profileId) },
|
||||
refreshActiveWatchSource = { profileId ->
|
||||
val result = WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.nuvio.app.features.trakt
|
|||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.encodeURLParameter
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
|
@ -13,6 +15,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
|
|
@ -35,6 +39,7 @@ object TraktAuthRepository {
|
|||
encodeDefaults = true
|
||||
}
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val refreshMutex = Mutex()
|
||||
|
||||
private val _uiState = MutableStateFlow(TraktAuthUiState())
|
||||
val uiState: StateFlow<TraktAuthUiState> = _uiState.asStateFlow()
|
||||
|
|
@ -65,24 +70,6 @@ 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()
|
||||
|
||||
|
|
@ -299,7 +286,6 @@ object TraktAuthRepository {
|
|||
)
|
||||
persist()
|
||||
refreshUserSettings()
|
||||
TraktCredentialSync.pushCurrentToRemote()
|
||||
publish(
|
||||
isLoading = false,
|
||||
statusMessage = localizedString(Res.string.trakt_connected_status),
|
||||
|
|
@ -341,12 +327,14 @@ object TraktAuthRepository {
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean {
|
||||
if (!hasRequiredCredentials()) return false
|
||||
val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() } ?: return false
|
||||
private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean = refreshMutex.withLock {
|
||||
if (!hasRequiredCredentials()) return@withLock false
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() }
|
||||
?: return@withLock false
|
||||
|
||||
if (!force && !isTokenExpiredOrExpiring(authState)) {
|
||||
return true
|
||||
return@withLock true
|
||||
}
|
||||
|
||||
val body = json.encodeToString(
|
||||
|
|
@ -359,29 +347,42 @@ object TraktAuthRepository {
|
|||
)
|
||||
|
||||
val response = runCatching {
|
||||
httpPostJsonWithHeaders(
|
||||
httpRequestRaw(
|
||||
method = "POST",
|
||||
url = "$BASE_URL/oauth/token",
|
||||
body = body,
|
||||
headers = emptyMap(),
|
||||
headers = mapOf(
|
||||
"Accept" to "application/json",
|
||||
"Content-Type" to "application/json",
|
||||
),
|
||||
)
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
log.w { "Trakt token refresh failed: ${error.message}" }
|
||||
}.getOrNull()
|
||||
log.w { "Trakt token refresh transport failure: ${error.message}" }
|
||||
}.getOrNull() ?: return@withLock false
|
||||
|
||||
if (response == null) {
|
||||
if (recoverFromRemoteCredentials(refreshToken)) return true
|
||||
return false
|
||||
if (ProfileRepository.activeProfileId != profileId || authState.refreshToken != refreshToken) {
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
when (traktTokenRefreshResponseAction(response.status)) {
|
||||
TraktTokenRefreshResponseAction.INVALIDATE -> {
|
||||
log.w { "Trakt rejected the refresh token with HTTP 400; clearing local credentials" }
|
||||
invalidateCredentials(profileId)
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
TraktTokenRefreshResponseAction.TRANSIENT_FAILURE -> {
|
||||
log.w { "Trakt token refresh failed with HTTP ${response.status}" }
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
TraktTokenRefreshResponseAction.ACCEPT -> Unit
|
||||
}
|
||||
|
||||
val parsed = runCatching {
|
||||
json.decodeFromString<TraktTokenResponse>(response)
|
||||
}.getOrNull()
|
||||
|
||||
if (parsed == null) {
|
||||
if (recoverFromRemoteCredentials(refreshToken)) return true
|
||||
return false
|
||||
}
|
||||
json.decodeFromString<TraktTokenResponse>(response.body)
|
||||
}.getOrNull() ?: return@withLock false
|
||||
|
||||
authState = authState.copy(
|
||||
accessToken = parsed.accessToken,
|
||||
|
|
@ -391,9 +392,19 @@ object TraktAuthRepository {
|
|||
expiresIn = parsed.expiresIn,
|
||||
)
|
||||
persist()
|
||||
TraktCredentialSync.pushCurrentToRemote()
|
||||
publish()
|
||||
return true
|
||||
true
|
||||
}
|
||||
|
||||
private suspend fun invalidateCredentials(profileId: Int) {
|
||||
authState = TraktAuthState()
|
||||
persist()
|
||||
publish(
|
||||
isLoading = false,
|
||||
statusMessage = null,
|
||||
errorMessage = localizedString(Res.string.trakt_authorization_expired_reconnect),
|
||||
)
|
||||
TraktCredentialSync.deleteRemote(profileId)
|
||||
}
|
||||
|
||||
private fun loadFromDisk() {
|
||||
|
|
@ -475,22 +486,18 @@ object TraktAuthRepository {
|
|||
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("|")
|
||||
internal enum class TraktTokenRefreshResponseAction {
|
||||
ACCEPT,
|
||||
INVALIDATE,
|
||||
TRANSIENT_FAILURE,
|
||||
}
|
||||
|
||||
internal fun traktTokenRefreshResponseAction(status: Int): TraktTokenRefreshResponseAction = when {
|
||||
status == 400 -> TraktTokenRefreshResponseAction.INVALIDATE
|
||||
status in 200..299 -> TraktTokenRefreshResponseAction.ACCEPT
|
||||
else -> TraktTokenRefreshResponseAction.TRANSIENT_FAILURE
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -8,100 +8,17 @@ 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.CancellationException
|
||||
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 =
|
||||
try {
|
||||
pullFromRemoteOrThrow(profileId)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
log.e(error) { "pullFromRemote(profileId=$profileId) failed" }
|
||||
false
|
||||
}
|
||||
|
||||
internal suspend fun pullFromRemoteOrThrow(
|
||||
profileId: Int = ProfileRepository.activeProfileId,
|
||||
): Boolean = mutex.withLock {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false
|
||||
val accountId = authState.userId
|
||||
if (ProfileRepository.activeProfileId != profileId) return@withLock false
|
||||
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_provider_credentials", params)
|
||||
val currentAuthState = AuthRepository.state.value
|
||||
if (
|
||||
currentAuthState !is AuthState.Authenticated ||
|
||||
currentAuthState.isAnonymous ||
|
||||
currentAuthState.userId != accountId ||
|
||||
ProfileRepository.activeProfileId != profileId
|
||||
) {
|
||||
throw CancellationException("Trakt credential pull target changed")
|
||||
}
|
||||
val rows = result.decodeList<ProviderCredentialRow>()
|
||||
val row = rows.firstOrNull { it.provider.equals(TRAKT_PROVIDER, ignoreCase = true) }
|
||||
?: return@withLock false
|
||||
val remoteState = row.credentialJson.toTraktAuthState()
|
||||
?: error("Remote Trakt credential payload is invalid")
|
||||
TraktAuthRepository.replaceStateFromSync(remoteState)
|
||||
}
|
||||
|
||||
suspend fun deleteRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean =
|
||||
mutex.withLock {
|
||||
val authState = AuthRepository.state.value
|
||||
|
|
@ -121,45 +38,3 @@ object TraktCredentialSync {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ class SyncManagerTest {
|
|||
fun `source prerequisites finish before source dependent pulls`() = runBlocking {
|
||||
val events = mutableListOf<String>()
|
||||
var profileSettingsApplied = false
|
||||
var traktCredentialsApplied = false
|
||||
|
||||
runOrderedProfileSync(
|
||||
profileId = 7,
|
||||
|
|
@ -28,20 +27,12 @@ class SyncManagerTest {
|
|||
profileSettingsApplied = true
|
||||
events += "settings:end"
|
||||
},
|
||||
pullTraktCredentials = {
|
||||
events += "credentials:start"
|
||||
yield()
|
||||
traktCredentialsApplied = true
|
||||
events += "credentials:end"
|
||||
},
|
||||
pullLibrary = {
|
||||
assertTrue(profileSettingsApplied)
|
||||
assertTrue(traktCredentialsApplied)
|
||||
events += "library"
|
||||
},
|
||||
refreshActiveWatchSource = {
|
||||
assertTrue(profileSettingsApplied)
|
||||
assertTrue(traktCredentialsApplied)
|
||||
events += "active-watch-source"
|
||||
},
|
||||
pullCollections = { events += "collections" },
|
||||
|
|
@ -50,10 +41,7 @@ class SyncManagerTest {
|
|||
onFailure = { _, error -> throw error },
|
||||
)
|
||||
|
||||
val lastPrerequisite = maxOf(
|
||||
events.indexOf("settings:end"),
|
||||
events.indexOf("credentials:end"),
|
||||
)
|
||||
val lastPrerequisite = events.indexOf("settings:end")
|
||||
assertTrue(events.indexOf("library") > lastPrerequisite)
|
||||
assertTrue(events.indexOf("active-watch-source") > lastPrerequisite)
|
||||
assertEquals(1, events.count { it == "active-watch-source" })
|
||||
|
|
@ -72,7 +60,7 @@ class SyncManagerTest {
|
|||
|
||||
assertTrue("plugins" !in events)
|
||||
assertTrue(events.indexOf("settings") < events.indexOf("library"))
|
||||
assertTrue(events.indexOf("credentials") < events.indexOf("active-watch-source"))
|
||||
assertTrue(events.indexOf("settings") < events.indexOf("active-watch-source"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -175,7 +163,6 @@ class SyncManagerTest {
|
|||
pullAddons = { events += "addons" },
|
||||
pullPlugins = { events += "plugins" },
|
||||
pullProfileSettings = { events += "settings" },
|
||||
pullTraktCredentials = { events += "credentials" },
|
||||
pullLibrary = { events += "library" },
|
||||
refreshActiveWatchSource = { events += "active-watch-source" },
|
||||
pullCollections = { events += "collections" },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class TraktAuthRepositoryTest {
|
||||
|
||||
@Test
|
||||
fun `HTTP 400 permanently invalidates a rejected refresh token`() {
|
||||
assertEquals(
|
||||
TraktTokenRefreshResponseAction.INVALIDATE,
|
||||
traktTokenRefreshResponseAction(400),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful token responses are accepted`() {
|
||||
assertEquals(
|
||||
TraktTokenRefreshResponseAction.ACCEPT,
|
||||
traktTokenRefreshResponseAction(200),
|
||||
)
|
||||
assertEquals(
|
||||
TraktTokenRefreshResponseAction.ACCEPT,
|
||||
traktTokenRefreshResponseAction(201),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-400 failures preserve credentials for a later attempt`() {
|
||||
listOf(401, 429, 500, 503).forEach { status ->
|
||||
assertEquals(
|
||||
TraktTokenRefreshResponseAction.TRANSIENT_FAILURE,
|
||||
traktTokenRefreshResponseAction(status),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue