mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-23 02:02:04 +00:00
feat(torbox): link device
This commit is contained in:
parent
075c5f8f51
commit
20f8717cf0
8 changed files with 445 additions and 10 deletions
|
|
@ -593,12 +593,25 @@
|
|||
<string name="settings_debrid_experimental_notice">Debrid-støtte er eksperimentell og kan endres eller fjernes senere.</string>
|
||||
<string name="settings_debrid_enable">Aktiver kilder</string>
|
||||
<string name="settings_debrid_enable_description">Vis spillbare resultater fra tilkoblede kontoer.</string>
|
||||
<string name="settings_debrid_add_key_first">Legg til en API-nøkkel først.</string>
|
||||
<string name="settings_debrid_add_key_first">Koble til en Debrid-konto først.</string>
|
||||
<string name="settings_debrid_section_providers">Konto</string>
|
||||
<string name="settings_debrid_provider_description">Koble til %1$s-kontoen din.</string>
|
||||
<string name="settings_debrid_provider_device_description">Koble til %1$s-kontoen din i nettleseren.</string>
|
||||
<string name="settings_debrid_dialog_title">%1$s API-nøkkel</string>
|
||||
<string name="settings_debrid_dialog_subtitle">Skriv inn API-nøkkelen din for %1$s.</string>
|
||||
<string name="settings_debrid_dialog_placeholder">Skriv inn %1$s API-nøkkel</string>
|
||||
<string name="settings_debrid_connected">Tilkoblet</string>
|
||||
<string name="settings_debrid_connect_provider">Koble til %1$s</string>
|
||||
<string name="settings_debrid_disconnect_provider">Koble fra %1$s</string>
|
||||
<string name="settings_debrid_disconnect">Koble fra</string>
|
||||
<string name="settings_debrid_device_auth_connected">%1$s er koblet til på denne enheten.</string>
|
||||
<string name="settings_debrid_device_auth_starting">Starter sikker innlogging...</string>
|
||||
<string name="settings_debrid_device_auth_instructions">Åpne lenken og skriv inn denne koden for å godkjenne Nuvio.</string>
|
||||
<string name="settings_debrid_device_auth_code_copied">Kode kopiert.</string>
|
||||
<string name="settings_debrid_device_auth_open">Åpne lenke</string>
|
||||
<string name="settings_debrid_device_auth_waiting">Venter på godkjenning...</string>
|
||||
<string name="settings_debrid_device_auth_failed">Kunne ikke starte innlogging.</string>
|
||||
<string name="settings_debrid_device_auth_expired">Denne koden er utløpt. Prøv igjen.</string>
|
||||
<string name="settings_debrid_section_instant_playback">Umiddelbar avspilling</string>
|
||||
<string name="settings_debrid_prepare_instant_playback">Forbered lenker</string>
|
||||
<string name="settings_debrid_prepare_instant_playback_description">Løs første kilder før avspilling starter.</string>
|
||||
|
|
@ -1147,7 +1160,7 @@
|
|||
<string name="streams_resume_from_time">Gjenoppta fra %1$s</string>
|
||||
<string name="streams_size">STØRRELSE %1$s</string>
|
||||
<string name="streams_torrent_not_supported">Denne strømtypen støttes ikke</string>
|
||||
<string name="debrid_missing_api_key">Legg til en Debrid API-nøkkel i Innstillinger.</string>
|
||||
<string name="debrid_missing_api_key">Koble til en Debrid-konto i Innstillinger.</string>
|
||||
<string name="debrid_stream_stale">Dette Debrid-resultatet er utgått. Oppdaterer strømmer.</string>
|
||||
<string name="debrid_resolve_failed">Kunne ikke løse denne Debrid-strømmen.</string>
|
||||
<string name="external_player_failed">Kunne ikke åpne ekstern avspiller</string>
|
||||
|
|
|
|||
|
|
@ -594,13 +594,26 @@
|
|||
<string name="settings_debrid_experimental_notice">Debrid support is experimental and may be kept, changed, or removed later.</string>
|
||||
<string name="settings_debrid_enable">Enable sources</string>
|
||||
<string name="settings_debrid_enable_description">Show playable results from connected accounts.</string>
|
||||
<string name="settings_debrid_add_key_first">Add an API key first.</string>
|
||||
<string name="settings_debrid_add_key_first">Connect a Debrid account first.</string>
|
||||
<string name="settings_debrid_section_providers">Account</string>
|
||||
<string name="settings_debrid_provider_description">Connect your %1$s account.</string>
|
||||
<string name="settings_debrid_provider_device_description">Link your %1$s account in the browser.</string>
|
||||
<string name="settings_debrid_dialog_title">%1$s API Key</string>
|
||||
<string name="settings_debrid_dialog_subtitle">Enter your %1$s API key.</string>
|
||||
<string name="settings_debrid_dialog_placeholder">Enter %1$s API key</string>
|
||||
<string name="settings_debrid_not_set">Not set</string>
|
||||
<string name="settings_debrid_connected">Connected</string>
|
||||
<string name="settings_debrid_connect_provider">Connect %1$s</string>
|
||||
<string name="settings_debrid_disconnect_provider">Disconnect %1$s</string>
|
||||
<string name="settings_debrid_disconnect">Disconnect</string>
|
||||
<string name="settings_debrid_device_auth_connected">%1$s is connected on this device.</string>
|
||||
<string name="settings_debrid_device_auth_starting">Starting secure sign-in...</string>
|
||||
<string name="settings_debrid_device_auth_instructions">Open the link and enter this code to approve Nuvio.</string>
|
||||
<string name="settings_debrid_device_auth_code_copied">Code copied.</string>
|
||||
<string name="settings_debrid_device_auth_open">Open link</string>
|
||||
<string name="settings_debrid_device_auth_waiting">Waiting for approval...</string>
|
||||
<string name="settings_debrid_device_auth_failed">Could not start sign-in.</string>
|
||||
<string name="settings_debrid_device_auth_expired">This code expired. Try again.</string>
|
||||
<string name="settings_debrid_section_instant_playback">Instant Playback</string>
|
||||
<string name="settings_debrid_prepare_instant_playback">Prepare links</string>
|
||||
<string name="settings_debrid_prepare_instant_playback_description">Resolve the first sources before playback starts.</string>
|
||||
|
|
@ -1152,7 +1165,7 @@
|
|||
<string name="streams_resume_from_time">Resume from %1$s</string>
|
||||
<string name="streams_size">SIZE %1$s</string>
|
||||
<string name="streams_torrent_not_supported">This stream type is not supported</string>
|
||||
<string name="debrid_missing_api_key">Add a Debrid API key in Settings.</string>
|
||||
<string name="debrid_missing_api_key">Connect a Debrid account in Settings.</string>
|
||||
<string name="debrid_not_cached">Not cached on Torbox.</string>
|
||||
<string name="debrid_stream_stale">This Debrid result expired. Refreshing streams.</string>
|
||||
<string name="debrid_resolve_failed">Could not resolve this Debrid stream.</string>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,26 @@ internal object DebridApiJson {
|
|||
internal object TorboxApiClient {
|
||||
private const val BASE_URL = "https://api.torbox.app"
|
||||
|
||||
suspend fun startDeviceAuthorization(
|
||||
appName: String,
|
||||
): DebridApiResponse<TorboxEnvelopeDto<TorboxDeviceAuthorizationDto>> =
|
||||
requestWithoutAuth(
|
||||
method = "GET",
|
||||
url = "$BASE_URL/v1/api/user/auth/device/start?${
|
||||
queryString("app" to appName)
|
||||
}",
|
||||
)
|
||||
|
||||
suspend fun redeemDeviceAuthorization(
|
||||
deviceCode: String,
|
||||
): DebridApiResponse<TorboxEnvelopeDto<TorboxDeviceTokenDto>> =
|
||||
requestWithoutAuth(
|
||||
method = "POST",
|
||||
url = "$BASE_URL/v1/api/user/auth/device/token",
|
||||
body = DebridApiJson.json.encodeToString(TorboxDeviceTokenRequestDto(deviceCode = deviceCode)),
|
||||
contentType = "application/json",
|
||||
)
|
||||
|
||||
suspend fun validateApiKey(apiKey: String): Boolean =
|
||||
getUser(apiKey.trim()).status in 200..299
|
||||
|
||||
|
|
@ -139,6 +159,29 @@ internal object TorboxApiClient {
|
|||
)
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> requestWithoutAuth(
|
||||
method: String,
|
||||
url: String,
|
||||
body: String = "",
|
||||
contentType: String? = null,
|
||||
): DebridApiResponse<T> {
|
||||
val headers = listOfNotNull(
|
||||
contentType?.let { "Content-Type" to it },
|
||||
"Accept" to "application/json",
|
||||
).toMap()
|
||||
val response = httpRequestRaw(
|
||||
method = method,
|
||||
url = url,
|
||||
headers = headers,
|
||||
body = body,
|
||||
)
|
||||
return DebridApiResponse(
|
||||
status = response.status,
|
||||
body = response.decodeBody<T>(),
|
||||
rawBody = response.body,
|
||||
)
|
||||
}
|
||||
|
||||
private fun authHeaders(apiKey: String): Map<String, String> =
|
||||
mapOf("Authorization" to "Bearer $apiKey")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,27 @@ internal data class TorboxCheckCachedRequestDto(
|
|||
val hashes: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class TorboxDeviceAuthorizationDto(
|
||||
@SerialName("device_code") val deviceCode: String? = null,
|
||||
val code: String? = null,
|
||||
@SerialName("verification_url") val verificationUrl: String? = null,
|
||||
@SerialName("friendly_verification_url") val friendlyVerificationUrl: String? = null,
|
||||
val interval: Int? = null,
|
||||
@SerialName("expires_at") val expiresAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class TorboxDeviceTokenRequestDto(
|
||||
@SerialName("device_code") val deviceCode: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class TorboxDeviceTokenDto(
|
||||
@SerialName("access_token") val accessToken: String? = null,
|
||||
@SerialName("token_type") val tokenType: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class TorboxCachedItemDto(
|
||||
val name: String? = null,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ data class DebridProvider(
|
|||
val displayName: String,
|
||||
val shortName: String,
|
||||
val visibleInUi: Boolean = true,
|
||||
val authMethod: DebridProviderAuthMethod = DebridProviderAuthMethod.ApiKey,
|
||||
val capabilities: Set<DebridProviderCapability> = emptySet(),
|
||||
)
|
||||
|
||||
|
|
@ -19,6 +20,11 @@ enum class DebridProviderCapability {
|
|||
LocalTorrentResolve,
|
||||
}
|
||||
|
||||
enum class DebridProviderAuthMethod {
|
||||
ApiKey,
|
||||
DeviceCode,
|
||||
}
|
||||
|
||||
object DebridProviders {
|
||||
const val TORBOX_ID = "torbox"
|
||||
const val REAL_DEBRID_ID = "realdebrid"
|
||||
|
|
@ -27,6 +33,7 @@ object DebridProviders {
|
|||
id = TORBOX_ID,
|
||||
displayName = "Torbox",
|
||||
shortName = "TB",
|
||||
authMethod = DebridProviderAuthMethod.DeviceCode,
|
||||
capabilities = setOf(
|
||||
DebridProviderCapability.ClientResolve,
|
||||
DebridProviderCapability.LocalTorrentCacheCheck,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ internal interface DebridProviderApi {
|
|||
|
||||
suspend fun validateApiKey(apiKey: String): Boolean
|
||||
|
||||
suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? = null
|
||||
|
||||
suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult =
|
||||
DebridDeviceAuthorizationTokenResult.Unsupported
|
||||
|
||||
suspend fun resolveClientStream(
|
||||
stream: StreamItem,
|
||||
apiKey: String,
|
||||
|
|
@ -29,6 +34,24 @@ internal object DebridProviderApis {
|
|||
}
|
||||
}
|
||||
|
||||
internal data class DebridDeviceAuthorization(
|
||||
val providerId: String,
|
||||
val deviceCode: String,
|
||||
val userCode: String,
|
||||
val verificationUrl: String,
|
||||
val friendlyVerificationUrl: String,
|
||||
val intervalSeconds: Int,
|
||||
val expiresAt: String?,
|
||||
)
|
||||
|
||||
internal sealed interface DebridDeviceAuthorizationTokenResult {
|
||||
data class Authorized(val accessToken: String) : DebridDeviceAuthorizationTokenResult
|
||||
data object Pending : DebridDeviceAuthorizationTokenResult
|
||||
data object Expired : DebridDeviceAuthorizationTokenResult
|
||||
data object Unsupported : DebridDeviceAuthorizationTokenResult
|
||||
data class Failed(val message: String?) : DebridDeviceAuthorizationTokenResult
|
||||
}
|
||||
|
||||
private class TorboxDebridProviderApi(
|
||||
private val fileSelector: TorboxFileSelector = TorboxFileSelector(),
|
||||
) : DebridProviderApi {
|
||||
|
|
@ -37,6 +60,55 @@ private class TorboxDebridProviderApi(
|
|||
override suspend fun validateApiKey(apiKey: String): Boolean =
|
||||
TorboxApiClient.validateApiKey(apiKey)
|
||||
|
||||
override suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? {
|
||||
val response = TorboxApiClient.startDeviceAuthorization(appName = appName)
|
||||
val data = response.body?.takeIf { response.isSuccessful && it.success != false }?.data
|
||||
?: return null
|
||||
val deviceCode = data.deviceCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
val userCode = data.code?.takeIf { it.isNotBlank() } ?: return null
|
||||
val verificationUrl = data.verificationUrl?.takeIf { it.isNotBlank() } ?: return null
|
||||
return DebridDeviceAuthorization(
|
||||
providerId = provider.id,
|
||||
deviceCode = deviceCode,
|
||||
userCode = userCode,
|
||||
verificationUrl = verificationUrl,
|
||||
friendlyVerificationUrl = data.friendlyVerificationUrl?.takeIf { it.isNotBlank() }
|
||||
?: verificationUrl,
|
||||
intervalSeconds = data.interval?.coerceAtLeast(1) ?: 5,
|
||||
expiresAt = data.expiresAt?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult {
|
||||
val normalized = deviceCode.trim()
|
||||
if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null)
|
||||
val response = TorboxApiClient.redeemDeviceAuthorization(deviceCode = normalized)
|
||||
val envelope = response.body
|
||||
val accessToken = envelope
|
||||
?.takeIf { response.isSuccessful && it.success != false }
|
||||
?.data
|
||||
?.accessToken
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (accessToken != null) {
|
||||
return DebridDeviceAuthorizationTokenResult.Authorized(accessToken)
|
||||
}
|
||||
val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody)
|
||||
.joinToString(" ")
|
||||
.lowercase()
|
||||
return when {
|
||||
message.contains("pending") || message.contains("not authorized") ->
|
||||
DebridDeviceAuthorizationTokenResult.Pending
|
||||
message.contains("expired") ->
|
||||
DebridDeviceAuthorizationTokenResult.Expired
|
||||
response.status == 404 || response.status == 409 || response.status == 425 ->
|
||||
DebridDeviceAuthorizationTokenResult.Pending
|
||||
response.status == 410 ->
|
||||
DebridDeviceAuthorizationTokenResult.Expired
|
||||
else ->
|
||||
DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resolveClientStream(
|
||||
stream: StreamItem,
|
||||
apiKey: String,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.material.icons.rounded.Check
|
|||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -30,6 +31,7 @@ import androidx.compose.material3.Surface
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -38,10 +40,18 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT
|
||||
import com.nuvio.app.features.debrid.DebridCredentialValidator
|
||||
import com.nuvio.app.features.debrid.DebridDeviceAuthorization
|
||||
import com.nuvio.app.features.debrid.DebridDeviceAuthorizationTokenResult
|
||||
import com.nuvio.app.features.debrid.DebridProvider
|
||||
import com.nuvio.app.features.debrid.DebridProviderApis
|
||||
import com.nuvio.app.features.debrid.DebridProviderAuthMethod
|
||||
import com.nuvio.app.features.debrid.DebridProviders
|
||||
import com.nuvio.app.features.debrid.DebridSettings
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
|
|
@ -58,16 +68,30 @@ import com.nuvio.app.features.debrid.DebridStreamSortDirection
|
|||
import com.nuvio.app.features.debrid.DebridStreamSortKey
|
||||
import com.nuvio.app.features.debrid.DebridStreamVisualTag
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_cancel
|
||||
import nuvio.composeapp.generated.resources.action_clear
|
||||
import nuvio.composeapp.generated.resources.action_retry
|
||||
import nuvio.composeapp.generated.resources.action_reset
|
||||
import nuvio.composeapp.generated.resources.action_save
|
||||
import nuvio.composeapp.generated.resources.action_saving
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_add_key_first
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_connected
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_connect_provider
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_disconnect_provider
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_device_auth_code_copied
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_device_auth_connected
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_device_auth_expired
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_device_auth_failed
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_device_auth_instructions
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_device_auth_open
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_device_auth_starting
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_device_auth_waiting
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_dialog_placeholder
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_dialog_subtitle
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_dialog_title
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_disconnect
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_enable
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_enable_description
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice
|
||||
|
|
@ -86,6 +110,7 @@ import nuvio.composeapp.generated.resources.settings_debrid_name_template
|
|||
import nuvio.composeapp.generated.resources.settings_debrid_name_template_description
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_not_set
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_provider_description
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_provider_device_description
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_section_formatting
|
||||
import nuvio.composeapp.generated.resources.settings_debrid_section_providers
|
||||
|
|
@ -127,8 +152,11 @@ internal fun LazyListScope.debridSettingsContent(
|
|||
}
|
||||
|
||||
item {
|
||||
var activeProviderId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var activeApiKeyProviderId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var activeDeviceAuthProviderId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val providers = remember { DebridProviders.visible() }
|
||||
val notSetLabel = stringResource(Res.string.settings_debrid_not_set)
|
||||
val connectedLabel = stringResource(Res.string.settings_debrid_connected)
|
||||
|
||||
SettingsSection(
|
||||
title = stringResource(Res.string.settings_debrid_section_providers),
|
||||
|
|
@ -142,16 +170,42 @@ internal fun LazyListScope.debridSettingsContent(
|
|||
DebridPreferenceRow(
|
||||
isTablet = isTablet,
|
||||
title = provider.displayName,
|
||||
description = stringResource(Res.string.settings_debrid_provider_description, provider.displayName),
|
||||
value = maskDebridApiKey(settings.apiKeyFor(provider.id), stringResource(Res.string.settings_debrid_not_set)),
|
||||
description = if (provider.authMethod == DebridProviderAuthMethod.DeviceCode) {
|
||||
stringResource(Res.string.settings_debrid_provider_device_description, provider.displayName)
|
||||
} else {
|
||||
stringResource(Res.string.settings_debrid_provider_description, provider.displayName)
|
||||
},
|
||||
value = providerCredentialStatus(
|
||||
provider = provider,
|
||||
credential = settings.apiKeyFor(provider.id),
|
||||
notSetLabel = notSetLabel,
|
||||
connectedLabel = connectedLabel,
|
||||
),
|
||||
enabled = true,
|
||||
onClick = { activeProviderId = provider.id },
|
||||
onClick = {
|
||||
when (provider.authMethod) {
|
||||
DebridProviderAuthMethod.DeviceCode -> activeDeviceAuthProviderId = provider.id
|
||||
DebridProviderAuthMethod.ApiKey -> activeApiKeyProviderId = provider.id
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeProviderId
|
||||
activeDeviceAuthProviderId
|
||||
?.let(DebridProviders::byId)
|
||||
?.let { provider ->
|
||||
DebridDeviceAuthDialog(
|
||||
provider = provider,
|
||||
currentValue = settings.apiKeyFor(provider.id),
|
||||
onConnected = { token -> DebridSettingsRepository.setProviderApiKey(provider.id, token) },
|
||||
onDisconnect = { DebridSettingsRepository.setProviderApiKey(provider.id, "") },
|
||||
onDismiss = { activeDeviceAuthProviderId = null },
|
||||
)
|
||||
}
|
||||
|
||||
activeApiKeyProviderId
|
||||
?.let(DebridProviders::byId)
|
||||
?.let { provider ->
|
||||
DebridApiKeyDialog(
|
||||
|
|
@ -161,7 +215,7 @@ internal fun LazyListScope.debridSettingsContent(
|
|||
placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder, provider.displayName),
|
||||
currentValue = settings.apiKeyFor(provider.id),
|
||||
onSave = { apiKey -> DebridSettingsRepository.setProviderApiKey(provider.id, apiKey) },
|
||||
onDismiss = { activeProviderId = null },
|
||||
onDismiss = { activeApiKeyProviderId = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1182,6 +1236,205 @@ private enum class DebridStreamPicker {
|
|||
EXCLUDED_RELEASE_GROUPS,
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun DebridDeviceAuthDialog(
|
||||
provider: DebridProvider,
|
||||
currentValue: String,
|
||||
onConnected: (String) -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val isConnected = currentValue.isNotBlank()
|
||||
var restartNonce by rememberSaveable(provider.id) { mutableStateOf(0) }
|
||||
var session by remember(provider.id, restartNonce, isConnected) { mutableStateOf<DebridDeviceAuthorization?>(null) }
|
||||
var isStarting by remember(provider.id, restartNonce, isConnected) { mutableStateOf(!isConnected) }
|
||||
var isPolling by remember(provider.id, restartNonce, isConnected) { mutableStateOf(false) }
|
||||
var statusMessage by remember(provider.id, restartNonce, isConnected) { mutableStateOf<String?>(null) }
|
||||
|
||||
val startingMessage = stringResource(Res.string.settings_debrid_device_auth_starting)
|
||||
val waitingMessage = stringResource(Res.string.settings_debrid_device_auth_waiting)
|
||||
val failedMessage = stringResource(Res.string.settings_debrid_device_auth_failed)
|
||||
val expiredMessage = stringResource(Res.string.settings_debrid_device_auth_expired)
|
||||
val codeCopiedMessage = stringResource(Res.string.settings_debrid_device_auth_code_copied)
|
||||
|
||||
LaunchedEffect(provider.id, restartNonce, isConnected) {
|
||||
if (isConnected) {
|
||||
isStarting = false
|
||||
isPolling = false
|
||||
statusMessage = null
|
||||
session = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
isStarting = true
|
||||
isPolling = false
|
||||
statusMessage = null
|
||||
session = runCatching {
|
||||
DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio")
|
||||
}.getOrNull()
|
||||
isStarting = false
|
||||
statusMessage = if (session == null) failedMessage else waitingMessage
|
||||
}
|
||||
|
||||
LaunchedEffect(session?.deviceCode, restartNonce, isConnected) {
|
||||
if (isConnected) return@LaunchedEffect
|
||||
val activeSession = session ?: return@LaunchedEffect
|
||||
while (true) {
|
||||
delay(activeSession.intervalSeconds.coerceAtLeast(1) * 1_000L)
|
||||
isPolling = true
|
||||
val result = runCatching {
|
||||
DebridProviderApis.apiFor(provider.id)
|
||||
?.redeemDeviceAuthorization(activeSession.deviceCode)
|
||||
?: DebridDeviceAuthorizationTokenResult.Unsupported
|
||||
}.getOrElse {
|
||||
DebridDeviceAuthorizationTokenResult.Failed(it.message)
|
||||
}
|
||||
isPolling = false
|
||||
when (result) {
|
||||
is DebridDeviceAuthorizationTokenResult.Authorized -> {
|
||||
onConnected(result.accessToken)
|
||||
onDismiss()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
DebridDeviceAuthorizationTokenResult.Pending -> {
|
||||
statusMessage = waitingMessage
|
||||
}
|
||||
|
||||
DebridDeviceAuthorizationTokenResult.Expired -> {
|
||||
statusMessage = expiredMessage
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
is DebridDeviceAuthorizationTokenResult.Failed,
|
||||
DebridDeviceAuthorizationTokenResult.Unsupported -> {
|
||||
statusMessage = failedMessage
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
DebridDialogSurface(
|
||||
title = stringResource(
|
||||
if (isConnected) Res.string.settings_debrid_disconnect_provider else Res.string.settings_debrid_connect_provider,
|
||||
provider.displayName,
|
||||
),
|
||||
) {
|
||||
if (isConnected) {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_debrid_device_auth_connected, provider.displayName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else if (isStarting) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||
Text(
|
||||
text = startingMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
session?.let { activeSession ->
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_debrid_device_auth_instructions),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
clipboardManager.setText(AnnotatedString(activeSession.userCode))
|
||||
statusMessage = codeCopiedMessage
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = activeSession.userCode,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = activeSession.friendlyVerificationUrl,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
statusMessage?.let { message ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isPolling) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (message == failedMessage || message == expiredMessage) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(Res.string.action_cancel))
|
||||
}
|
||||
if (isConnected) {
|
||||
Button(
|
||||
onClick = {
|
||||
onDisconnect()
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.settings_debrid_disconnect))
|
||||
}
|
||||
}
|
||||
if (!isConnected && !isStarting && session == null) {
|
||||
TextButton(onClick = { restartNonce += 1 }) {
|
||||
Text(stringResource(Res.string.action_retry))
|
||||
}
|
||||
}
|
||||
if (!isConnected) session?.let { activeSession ->
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching { uriHandler.openUri(activeSession.verificationUrl) }
|
||||
.onFailure { statusMessage = failedMessage }
|
||||
},
|
||||
enabled = !isStarting,
|
||||
) {
|
||||
Text(stringResource(Res.string.settings_debrid_device_auth_open))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun DebridApiKeyDialog(
|
||||
|
|
@ -1287,6 +1540,17 @@ private fun maskDebridApiKey(key: String, notSetLabel: String): String {
|
|||
return if (trimmed.length <= 4) "****" else "******${trimmed.takeLast(4)}"
|
||||
}
|
||||
|
||||
private fun providerCredentialStatus(
|
||||
provider: DebridProvider,
|
||||
credential: String,
|
||||
notSetLabel: String,
|
||||
connectedLabel: String,
|
||||
): String =
|
||||
when (provider.authMethod) {
|
||||
DebridProviderAuthMethod.DeviceCode -> if (credential.isBlank()) notSetLabel else connectedLabel
|
||||
DebridProviderAuthMethod.ApiKey -> maskDebridApiKey(credential, notSetLabel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebridInfoRow(
|
||||
isTablet: Boolean,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import kotlin.test.assertTrue
|
|||
class DebridProviderTest {
|
||||
@Test
|
||||
fun `torbox exposes local addon capabilities`() {
|
||||
assertTrue(DebridProviders.Torbox.authMethod == DebridProviderAuthMethod.DeviceCode)
|
||||
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.ClientResolve))
|
||||
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentCacheCheck))
|
||||
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentResolve))
|
||||
|
|
@ -14,6 +15,7 @@ class DebridProviderTest {
|
|||
|
||||
@Test
|
||||
fun `real debrid stays hidden from local addon capability paths`() {
|
||||
assertTrue(DebridProviders.RealDebrid.authMethod == DebridProviderAuthMethod.ApiKey)
|
||||
assertFalse(DebridProviders.RealDebrid.visibleInUi)
|
||||
assertTrue(DebridProviders.RealDebrid.supports(DebridProviderCapability.ClientResolve))
|
||||
assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentCacheCheck))
|
||||
|
|
|
|||
Loading…
Reference in a new issue