From 8c25cca724125cf415d05516b9deb0b177864a0f Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Thu, 21 May 2026 14:37:44 +0530
Subject: [PATCH] feat(cloud): adding premimize
---
composeApp/build.gradle.kts | 13 ++
.../debrid/DebridSettingsStorage.android.kt | 10 +
.../composeResources/values-no/strings.xml | 3 +
.../composeResources/values/strings.xml | 6 +-
.../app/features/cloud/CloudLibraryModels.kt | 2 +
.../features/cloud/CloudLibraryProviderApi.kt | 1 +
.../PremiumizeCloudLibraryProviderApi.kt | 164 +++++++++++++++++
.../cloud/TorboxCloudLibraryProviderApi.kt | 2 +
.../app/features/debrid/DebridApiClients.kt | 174 ++++++++++++++++++
.../app/features/debrid/DebridApiModels.kt | 99 ++++++++++
.../features/debrid/DebridFileSelectors.kt | 51 +++++
.../app/features/debrid/DebridProvider.kt | 29 ++-
.../app/features/debrid/DebridProviderApis.kt | 142 ++++++++++++++
.../app/features/debrid/DebridSettings.kt | 18 +-
.../debrid/DebridSettingsRepository.kt | 68 ++++++-
.../features/debrid/DebridSettingsStorage.kt | 2 +
.../debrid/DebridStreamPresentation.kt | 10 +-
.../features/debrid/DirectDebridResolver.kt | 29 ++-
.../debrid/DirectDebridStreamPreparer.kt | 2 +-
.../debrid/LocalDebridAvailabilityService.kt | 4 +-
.../app/features/debrid/LocalDebridService.kt | 37 ++++
.../app/features/library/LibraryScreen.kt | 1 +
.../features/settings/DebridSettingsPage.kt | 89 ++++++++-
.../PremiumizeCloudLibraryProviderApiTest.kt | 67 +++++++
.../features/debrid/DebridFileSelectorTest.kt | 36 ++++
.../app/features/debrid/DebridProviderTest.kt | 10 +
.../app/features/debrid/DebridSettingsTest.kt | 22 ++-
.../debrid/PremiumizeDeviceAuthTest.kt | 62 +++++++
.../debrid/DebridSettingsStorage.ios.kt | 10 +
29 files changed, 1132 insertions(+), 31 deletions(-)
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt
create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt
create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/PremiumizeDeviceAuthTest.kt
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 5c5811e4..98455633 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -90,6 +90,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
)
}
+ outDir.resolve("com/nuvio/app/features/debrid").apply {
+ mkdirs()
+ resolve("PremiumizeConfig.kt").writeText(
+ """
+ |package com.nuvio.app.features.debrid
+ |
+ |object PremiumizeConfig {
+ | const val CLIENT_ID = "${props.getProperty("PREMIUMIZE_CLIENT_ID", "")}"
+ |}
+ """.trimMargin()
+ )
+ }
+
outDir.resolve("com/nuvio/app/core/build").apply {
mkdirs()
resolve("AppVersionConfig.kt").writeText(
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt
index f7cd154a..de8e76b1 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt
@@ -17,6 +17,7 @@ actual object DebridSettingsStorage {
private const val preferencesName = "nuvio_debrid_settings"
private const val enabledKey = "debrid_enabled"
private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled"
+ private const val preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id"
private const val torboxApiKeyKey = "debrid_torbox_api_key"
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
@@ -33,6 +34,7 @@ actual object DebridSettingsStorage {
listOf(
enabledKey,
cloudLibraryEnabledKey,
+ preferredResolverProviderIdKey,
instantPlaybackPreparationLimitKey,
streamMaxResultsKey,
streamSortModeKey,
@@ -63,6 +65,12 @@ actual object DebridSettingsStorage {
saveBoolean(cloudLibraryEnabledKey, enabled)
}
+ actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey)
+
+ actual fun savePreferredResolverProviderId(providerId: String) {
+ saveString(preferredResolverProviderIdKey, providerId)
+ }
+
actual fun loadProviderApiKey(providerId: String): String? =
loadString(providerApiKeyKey(providerId))
@@ -189,6 +197,7 @@ actual object DebridSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) }
+ loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) }
DebridProviders.all().forEach { provider ->
loadProviderApiKey(provider.id)?.let {
put(providerApiKeyKey(provider.id), encodeSyncString(it))
@@ -213,6 +222,7 @@ actual object DebridSettingsStorage {
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled)
+ payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId)
DebridProviders.all().forEach { provider ->
payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey ->
saveProviderApiKey(provider.id, apiKey)
diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml
index e8d5de08..c80318d5 100644
--- a/composeApp/src/commonMain/composeResources/values-no/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml
@@ -595,6 +595,8 @@
Bla gjennom og spill filer som allerede finnes i tilkoblede skytjenester.
Løs spillbare lenker
Be en tilkoblet tjeneste om spillbare lenker når et resultat trenger det. Dette kan legge elementet til i den tjenesten.
+ Løs med
+ Velg hvilken tilkoblet skytjeneste som håndterer spillbare lenker.
Koble til en skytjenestekonto først.
Skytjenester
Koble til %1$s-kontoen din.
@@ -613,6 +615,7 @@
Åpne lenke
Venter på godkjenning...
Kunne ikke starte innlogging.
+ Denne innloggingsmetoden er ikke konfigurert i denne versjonen.
Denne koden er utløpt. Prøv igjen.
Lenkeforberedelse
Forbered lenker
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index f26a2718..e75ecbdf 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -596,6 +596,8 @@
Browse and play files already in your connected cloud services.
Resolve playable links
Ask a connected service for playable links when a result needs it. This may add the item to that service.
+ Resolve with
+ Choose which connected cloud service manages playable links.
Connect a cloud service account first.
Cloud Services
Connect your %1$s account.
@@ -615,6 +617,7 @@
Open link
Waiting for approval...
Could not start sign-in.
+ This sign-in method is not configured in this build.
This code expired. Try again.
Link Preparation
Prepare links
@@ -1330,7 +1333,7 @@
Couldn't load Trakt library
Trakt Library
Connect account
- Connect Torbox in Cloud Services settings to browse playable files from your cloud library.
+ Connect a cloud service in Cloud Services settings to browse playable files from your cloud library.
No cloud account connected
Open Cloud Services
Turn on Cloud library in Cloud Services settings to browse files from connected accounts.
@@ -1352,6 +1355,7 @@
Torrents
Usenet
Web
+ Files
Anime
Channels
Movies
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt
index 4ee81729..dfdbcdfd 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt
@@ -6,6 +6,7 @@ enum class CloudLibraryItemType {
Torrent,
Usenet,
WebDownload,
+ File,
}
data class CloudLibraryFile(
@@ -14,6 +15,7 @@ data class CloudLibraryFile(
val sizeBytes: Long? = null,
val mimeType: String? = null,
val playable: Boolean = true,
+ val playbackUrl: String? = null,
) {
val stableKey: String
get() = id ?: name
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt
index f87ca151..d9e98970 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt
@@ -18,6 +18,7 @@ internal interface CloudLibraryProviderApi {
internal object CloudLibraryProviderApis {
private val registered = listOf(
TorboxCloudLibraryProviderApi(),
+ PremiumizeCloudLibraryProviderApi(),
)
fun all(): List = registered
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt
new file mode 100644
index 00000000..b6802543
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt
@@ -0,0 +1,164 @@
+package com.nuvio.app.features.cloud
+
+import com.nuvio.app.features.debrid.DebridProviders
+import com.nuvio.app.features.debrid.PremiumizeApiClient
+import com.nuvio.app.features.debrid.PremiumizeCloudFileDto
+import kotlinx.coroutines.CancellationException
+
+internal class PremiumizeCloudLibraryProviderApi : CloudLibraryProviderApi {
+ override val provider = DebridProviders.Premiumize
+
+ override suspend fun listItems(apiKey: String): Result> =
+ runCatching {
+ val response = PremiumizeApiClient.listAllItems(apiKey)
+ if (!response.isSuccessful || response.body?.status.equals("error", ignoreCase = true)) {
+ throw IllegalStateException(response.body?.message ?: response.body?.code ?: response.rawBody.takeIf { it.isNotBlank() })
+ }
+ premiumizeCloudItemsFromFiles(
+ files = response.body?.files.orEmpty(),
+ providerId = provider.id,
+ providerName = provider.displayName,
+ )
+ }
+
+ override suspend fun resolvePlayback(
+ apiKey: String,
+ item: CloudLibraryItem,
+ file: CloudLibraryFile,
+ ): CloudLibraryPlaybackResult {
+ if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable
+ file.playbackUrl?.takeIf { it.isNotBlank() }?.let { url ->
+ return CloudLibraryPlaybackResult.Success(
+ url = url,
+ filename = file.name.takeIf { it.isNotBlank() },
+ videoSizeBytes = file.sizeBytes,
+ )
+ }
+
+ val fileId = file.id?.takeIf { it.isNotBlank() } ?: return CloudLibraryPlaybackResult.Failed()
+ return try {
+ val response = PremiumizeApiClient.itemDetails(apiKey = apiKey, itemId = fileId)
+ if (!response.isSuccessful || response.body?.status.equals("error", ignoreCase = true)) {
+ return CloudLibraryPlaybackResult.Failed(response.body?.message ?: response.body?.code)
+ }
+ val url = response.body?.link?.takeIf { it.isNotBlank() }
+ ?: return CloudLibraryPlaybackResult.Failed()
+ CloudLibraryPlaybackResult.Success(
+ url = url,
+ filename = response.body.name?.takeIf { it.isNotBlank() } ?: file.name.takeIf { it.isNotBlank() },
+ videoSizeBytes = response.body.size ?: file.sizeBytes,
+ )
+ } catch (error: Exception) {
+ if (error is CancellationException) throw error
+ CloudLibraryPlaybackResult.Failed(error.message)
+ }
+ }
+}
+
+internal fun premiumizeCloudItemsFromFiles(
+ files: List,
+ providerId: String,
+ providerName: String,
+): List {
+ val mappedFiles = files.mapNotNull { it.toPremiumizeCloudFile() }
+ val groups = mappedFiles.groupBy { file ->
+ file.groupKey
+ }
+ return groups.values
+ .mapNotNull { group ->
+ val first = group.firstOrNull() ?: return@mapNotNull null
+ val cloudFiles = group
+ .map { it.file }
+ .sortedWith(compareBy { !it.playable }.thenBy { it.name.lowercase() })
+ val size = cloudFiles
+ .mapNotNull { it.sizeBytes }
+ .takeIf { it.isNotEmpty() }
+ ?.sum()
+ CloudLibraryItem(
+ providerId = providerId,
+ providerName = providerName,
+ id = first.itemId,
+ type = CloudLibraryItemType.File,
+ name = first.itemName,
+ status = "Ready",
+ sizeBytes = size,
+ files = cloudFiles,
+ )
+ }
+ .sortedBy { it.name.lowercase() }
+}
+
+private data class PremiumizeMappedCloudFile(
+ val groupKey: String,
+ val itemId: String,
+ val itemName: String,
+ val file: CloudLibraryFile,
+)
+
+private fun PremiumizeCloudFileDto.toPremiumizeCloudFile(): PremiumizeMappedCloudFile? {
+ val normalizedPath = path?.trim()?.trim('/')?.takeIf { it.isNotBlank() }
+ val fileName = name?.trim()?.takeIf { it.isNotBlank() }
+ ?: normalizedPath?.pathBasename()?.takeIf { it.isNotBlank() }
+ ?: return null
+ val fileId = id?.trim()?.takeIf { it.isNotBlank() }
+ val playable = isPlayablePremiumizeCloudFile(name = fileName, mimeType = mimeType)
+ val segments = normalizedPath
+ ?.split('/')
+ ?.map { it.trim() }
+ ?.filter { it.isNotBlank() }
+ .orEmpty()
+ val topLevel = segments.firstOrNull()
+ val isRootFile = segments.size <= 1
+ val itemName = if (isRootFile) fileName else topLevel ?: fileName
+ val itemId = if (isRootFile) {
+ "file:${fileId ?: normalizedPath ?: fileName}"
+ } else {
+ "folder:${topLevel ?: itemName}"
+ }
+ val groupKey = if (isRootFile) itemId else "folder:${topLevel ?: itemName}"
+ return PremiumizeMappedCloudFile(
+ groupKey = groupKey,
+ itemId = itemId,
+ itemName = itemName,
+ file = CloudLibraryFile(
+ id = fileId,
+ name = fileName,
+ sizeBytes = size,
+ mimeType = mimeType,
+ playable = playable,
+ playbackUrl = link?.takeIf { playable && it.isNotBlank() },
+ ),
+ )
+}
+
+private fun String.pathBasename(): String =
+ substringAfterLast('/').substringAfterLast('\\')
+
+private fun isPlayablePremiumizeCloudFile(name: String, mimeType: String?): Boolean {
+ val normalizedMime = mimeType?.lowercase().orEmpty()
+ if (normalizedMime.startsWith("video/")) return true
+ val extension = name.substringAfterLast('.', missingDelimiterValue = "")
+ .lowercase()
+ return extension in premiumizePlayableVideoExtensions
+}
+
+private val premiumizePlayableVideoExtensions = setOf(
+ "3g2",
+ "3gp",
+ "avi",
+ "divx",
+ "flv",
+ "m2ts",
+ "m4v",
+ "mkv",
+ "mov",
+ "mp4",
+ "mpeg",
+ "mpg",
+ "mts",
+ "ogm",
+ "ogv",
+ "ts",
+ "webm",
+ "wmv",
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt
index 33ba2608..ab098ffd 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt
@@ -42,6 +42,7 @@ internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi {
webId = item.id,
fileId = file.id,
)
+ CloudLibraryItemType.File -> return CloudLibraryPlaybackResult.Failed()
}
if (!response.isSuccessful || response.body?.success == false) {
return CloudLibraryPlaybackResult.Failed(response.body?.detail ?: response.body?.error)
@@ -150,6 +151,7 @@ internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String =
CloudLibraryItemType.Torrent -> "torrent_id"
CloudLibraryItemType.Usenet -> "usenet_id"
CloudLibraryItemType.WebDownload -> "web_id"
+ CloudLibraryItemType.File -> "file_id"
}
private fun List.firstNonBlank(): String? =
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt
index 485d59dc..f27870be 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt
@@ -362,6 +362,180 @@ internal object RealDebridApiClient {
mapOf("Authorization" to "Bearer $apiKey")
}
+internal object PremiumizeApiClient {
+ private const val BASE_URL = "https://www.premiumize.me"
+
+ suspend fun validateApiKey(apiKey: String): Boolean {
+ val response = accountInfo(apiKey.trim())
+ return response.isSuccessful && response.body?.isSuccess == true
+ }
+
+ suspend fun startDeviceAuthorization(
+ clientId: String,
+ ): DebridApiResponse =
+ formRequestWithoutAuth(
+ method = "POST",
+ url = "$BASE_URL/token",
+ fields = listOf(
+ "response_type" to "device_code",
+ "client_id" to clientId,
+ ),
+ )
+
+ suspend fun redeemDeviceAuthorization(
+ clientId: String,
+ deviceCode: String,
+ ): DebridApiResponse =
+ formRequestWithoutAuth(
+ method = "POST",
+ url = "$BASE_URL/token",
+ fields = listOf(
+ "grant_type" to "device_code",
+ "code" to deviceCode,
+ "client_id" to clientId,
+ ),
+ )
+
+ suspend fun accountInfo(apiKey: String): DebridApiResponse =
+ request(
+ method = "GET",
+ url = "$BASE_URL/api/account/info",
+ apiKey = apiKey,
+ )
+
+ suspend fun listAllItems(apiKey: String): DebridApiResponse =
+ request(
+ method = "GET",
+ url = "$BASE_URL/api/item/listall",
+ apiKey = apiKey,
+ )
+
+ suspend fun itemDetails(
+ apiKey: String,
+ itemId: String,
+ ): DebridApiResponse =
+ request(
+ method = "GET",
+ url = "$BASE_URL/api/item/details?${queryString("id" to itemId)}",
+ apiKey = apiKey,
+ )
+
+ suspend fun directDownload(
+ apiKey: String,
+ source: String,
+ ): DebridApiResponse =
+ formRequest(
+ method = "POST",
+ url = "$BASE_URL/api/transfer/directdl",
+ apiKey = apiKey,
+ fields = listOf("src" to source),
+ )
+
+ suspend fun checkCache(
+ apiKey: String,
+ items: List,
+ ): DebridApiResponse {
+ val normalizedItems = items.map { it.trim() }.filter { it.isNotBlank() }
+ if (normalizedItems.isEmpty()) {
+ return DebridApiResponse(
+ status = 200,
+ body = PremiumizeCacheCheckDto(status = "success", response = emptyList()),
+ rawBody = "",
+ )
+ }
+ return formRequest(
+ method = "POST",
+ url = "$BASE_URL/api/cache/check",
+ apiKey = apiKey,
+ fields = normalizedItems.map { "items[]" to it },
+ )
+ }
+
+ private suspend inline fun formRequestWithoutAuth(
+ method: String,
+ url: String,
+ fields: List>,
+ ): DebridApiResponse =
+ requestWithoutAuth(
+ method = method,
+ url = url,
+ body = formBody(fields),
+ contentType = "application/x-www-form-urlencoded",
+ )
+
+ private suspend inline fun formRequest(
+ method: String,
+ url: String,
+ apiKey: String,
+ fields: List>,
+ ): DebridApiResponse =
+ request(
+ method = method,
+ url = url,
+ apiKey = apiKey,
+ body = formBody(fields),
+ contentType = "application/x-www-form-urlencoded",
+ )
+
+ private suspend inline fun requestWithoutAuth(
+ method: String,
+ url: String,
+ body: String = "",
+ contentType: String? = null,
+ ): DebridApiResponse {
+ 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(),
+ rawBody = response.body,
+ )
+ }
+
+ private suspend inline fun request(
+ method: String,
+ url: String,
+ apiKey: String,
+ body: String = "",
+ contentType: String? = null,
+ ): DebridApiResponse {
+ val headers = authHeaders(apiKey) + listOfNotNull(
+ contentType?.let { "Content-Type" to it },
+ "Accept" to "application/json",
+ )
+ val response = httpRequestRaw(
+ method = method,
+ url = url,
+ headers = headers,
+ body = body,
+ )
+ return DebridApiResponse(
+ status = response.status,
+ body = response.decodeBody(),
+ rawBody = response.body,
+ )
+ }
+
+ private fun formBody(fields: List>): String =
+ fields.joinToString("&") { (key, value) ->
+ "${encodeFormValue(key)}=${encodeFormValue(value)}"
+ }
+
+ private fun authHeaders(apiKey: String): Map =
+ mapOf("Authorization" to "Bearer $apiKey")
+
+ private val PremiumizeAccountInfoDto.isSuccess: Boolean
+ get() = status.equals("success", ignoreCase = true)
+}
+
object DebridCredentialValidator {
suspend fun validateProvider(providerId: String, apiKey: String): Boolean {
val normalized = apiKey.trim()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt
index a2c27de2..4c484212 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt
@@ -151,3 +151,102 @@ internal data class RealDebridUnrestrictLinkDto(
val streamable: Int? = null,
val type: String? = null,
)
+
+@Serializable
+internal data class PremiumizeDeviceAuthorizationDto(
+ @SerialName("device_code") val deviceCode: String? = null,
+ @SerialName("user_code") val userCode: String? = null,
+ @SerialName("verification_uri") val verificationUri: String? = null,
+ @SerialName("verification_uri_complete") val verificationUriComplete: String? = null,
+ @SerialName("expires_in") val expiresIn: Int? = null,
+ val interval: Int? = null,
+ val error: String? = null,
+ @SerialName("error_description") val errorDescription: String? = null,
+)
+
+@Serializable
+internal data class PremiumizeDeviceTokenDto(
+ @SerialName("access_token") val accessToken: String? = null,
+ @SerialName("token_type") val tokenType: String? = null,
+ @SerialName("expires_in") val expiresIn: Int? = null,
+ val scope: String? = null,
+ val error: String? = null,
+ @SerialName("error_description") val errorDescription: String? = null,
+)
+
+@Serializable
+internal data class PremiumizeApiEnvelopeDto(
+ val status: String? = null,
+ val message: String? = null,
+ val code: String? = null,
+)
+
+@Serializable
+internal data class PremiumizeAccountInfoDto(
+ val status: String? = null,
+ val message: String? = null,
+ val code: String? = null,
+ @SerialName("customer_id") val customerId: String? = null,
+ @SerialName("premium_until") val premiumUntil: Long? = null,
+ @SerialName("limit_used") val limitUsed: Double? = null,
+ @SerialName("booster_points") val boosterPoints: Int? = null,
+)
+
+@Serializable
+internal data class PremiumizeDirectDownloadDto(
+ val status: String? = null,
+ val message: String? = null,
+ val code: String? = null,
+ val content: List? = null,
+)
+
+@Serializable
+internal data class PremiumizeDirectDownloadFileDto(
+ val path: String? = null,
+ val size: Long? = null,
+ val link: String? = null,
+)
+
+@Serializable
+internal data class PremiumizeCacheCheckDto(
+ val status: String? = null,
+ val message: String? = null,
+ val code: String? = null,
+ val response: List? = null,
+ val filename: List? = null,
+ val filesize: List? = null,
+)
+
+@Serializable
+internal data class PremiumizeItemListAllDto(
+ val status: String? = null,
+ val message: String? = null,
+ val code: String? = null,
+ val files: List? = null,
+)
+
+@Serializable
+internal data class PremiumizeCloudFileDto(
+ val id: String? = null,
+ val name: String? = null,
+ val path: String? = null,
+ val type: String? = null,
+ val size: Long? = null,
+ @SerialName("created_at") val createdAt: Long? = null,
+ @SerialName("mime_type") val mimeType: String? = null,
+ val link: String? = null,
+)
+
+@Serializable
+internal data class PremiumizeItemDetailsDto(
+ val status: String? = null,
+ val message: String? = null,
+ val code: String? = null,
+ val id: String? = null,
+ val name: String? = null,
+ val size: Long? = null,
+ @SerialName("created_at") val createdAt: Long? = null,
+ @SerialName("folder_id") val folderId: String? = null,
+ @SerialName("mime_type") val mimeType: String? = null,
+ val link: String? = null,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt
index 0718df7a..d70a7001 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt
@@ -107,6 +107,57 @@ internal class RealDebridFileSelector {
displayName().lowercase().hasVideoExtension()
}
+internal class PremiumizeDirectDownloadFileSelector {
+ fun selectFile(
+ files: List,
+ resolve: StreamClientResolve,
+ season: Int?,
+ episode: Int?,
+ ): PremiumizeDirectDownloadFileDto? {
+ val playable = files.filter { it.isPlayableVideo() }
+ if (playable.isEmpty()) return null
+
+ val episodePatterns = buildEpisodePatterns(
+ season = season ?: resolve.season,
+ episode = episode ?: resolve.episode,
+ )
+ val names = resolve.specificFileNames(episodePatterns)
+ if (names.isNotEmpty()) {
+ playable.firstNameMatch(names) { it.displayName() }?.let {
+ return it
+ }
+ }
+
+ if (episodePatterns.isNotEmpty()) {
+ playable.firstOrNull { file ->
+ val fileName = file.displayName().lowercase()
+ episodePatterns.any { pattern -> fileName.contains(pattern) }
+ }?.let {
+ return it
+ }
+ }
+
+ resolve.fileIdx?.let { fileIdx ->
+ files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let {
+ return it
+ }
+ if (fileIdx > 0) {
+ files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let {
+ return it
+ }
+ }
+ }
+
+ return playable.maxByOrNull { it.size ?: 0L }
+ }
+
+ private fun PremiumizeDirectDownloadFileDto.isPlayableVideo(): Boolean =
+ !link.isNullOrBlank() && displayName().lowercase().hasVideoExtension()
+}
+
+internal fun PremiumizeDirectDownloadFileDto.displayName(): String =
+ path.orEmpty().substringAfterLast('/').substringAfterLast('\\').ifBlank { path.orEmpty() }
+
private fun String.normalizedName(): String =
substringAfterLast('/')
.substringBeforeLast('.')
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt
index 93e75362..62b6b0fa 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt
@@ -28,6 +28,7 @@ enum class DebridProviderAuthMethod {
object DebridProviders {
const val TORBOX_ID = "torbox"
+ const val PREMIUMIZE_ID = "premiumize"
const val REAL_DEBRID_ID = "realdebrid"
val Torbox = DebridProvider(
@@ -43,6 +44,19 @@ object DebridProviders {
),
)
+ val Premiumize = DebridProvider(
+ id = PREMIUMIZE_ID,
+ displayName = "Premiumize",
+ shortName = "PM",
+ authMethod = DebridProviderAuthMethod.DeviceCode,
+ capabilities = setOf(
+ DebridProviderCapability.ClientResolve,
+ DebridProviderCapability.LocalTorrentCacheCheck,
+ DebridProviderCapability.LocalTorrentResolve,
+ DebridProviderCapability.CloudLibrary,
+ ),
+ )
+
val RealDebrid = DebridProvider(
id = REAL_DEBRID_ID,
displayName = "Real-Debrid",
@@ -51,7 +65,7 @@ object DebridProviders {
capabilities = setOf(DebridProviderCapability.ClientResolve),
)
- private val registered = listOf(Torbox, RealDebrid)
+ private val registered = listOf(Torbox, Premiumize, RealDebrid)
fun all(): List = registered
@@ -85,6 +99,19 @@ object DebridProviders {
?.let { apiKey -> DebridServiceCredential(provider, apiKey) }
}
+ fun configuredResolverServices(settings: DebridSettings): List =
+ configuredServices(settings).filter { credential ->
+ credential.provider.supports(DebridProviderCapability.ClientResolve) ||
+ credential.provider.supports(DebridProviderCapability.LocalTorrentResolve)
+ }
+
+ fun preferredResolverService(settings: DebridSettings): DebridServiceCredential? {
+ val services = configuredResolverServices(settings)
+ if (services.isEmpty()) return null
+ val preferredId = byId(settings.preferredResolverProviderId)?.id
+ return services.firstOrNull { it.provider.id == preferredId } ?: services.firstOrNull()
+ }
+
fun configuredSourceNames(settings: DebridSettings): List =
configuredServices(settings).map { instantName(it.provider.id) }
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt
index b3bd1ac1..9ebe9616 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt
@@ -25,6 +25,7 @@ internal interface DebridProviderApi {
internal object DebridProviderApis {
private val registered = listOf(
TorboxDebridProviderApi(),
+ PremiumizeDebridProviderApi(),
RealDebridProviderApi(),
)
@@ -157,6 +158,60 @@ private class TorboxDebridProviderApi(
}
}
+internal class PremiumizeDebridProviderApi(
+ private val fileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(),
+ private val clientIdProvider: () -> String = { PremiumizeConfig.CLIENT_ID },
+) : DebridProviderApi {
+ override val provider: DebridProvider = DebridProviders.Premiumize
+
+ override suspend fun validateApiKey(apiKey: String): Boolean =
+ PremiumizeApiClient.validateApiKey(apiKey)
+
+ override suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? {
+ val clientId = premiumizeClientIdOrThrow()
+ val response = PremiumizeApiClient.startDeviceAuthorization(clientId = clientId)
+ return premiumizeDeviceAuthorizationFromResponse(response, provider.id)
+ }
+
+ override suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult {
+ val clientId = premiumizeClientIdOrThrow()
+ val normalized = deviceCode.trim()
+ if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null)
+ val response = PremiumizeApiClient.redeemDeviceAuthorization(
+ clientId = clientId,
+ deviceCode = normalized,
+ )
+ return premiumizeDeviceAuthorizationTokenResult(response)
+ }
+
+ override suspend fun resolveClientStream(
+ stream: StreamItem,
+ apiKey: String,
+ season: Int?,
+ episode: Int?,
+ ): DirectDebridResolveResult {
+ val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
+ val source = resolve.magnetUri?.takeIf { it.isNotBlank() }
+ ?: buildMagnetUri(resolve)
+ ?: stream.playableDirectUrl?.takeIf { it.isNotBlank() }
+ ?: return DirectDebridResolveResult.Stale
+ return resolvePremiumizeDirectDownload(
+ apiKey = apiKey,
+ source = source,
+ resolve = resolve,
+ season = season,
+ episode = episode,
+ fallbackFilename = stream.behaviorHints.filename,
+ fallbackSize = stream.behaviorHints.videoSize,
+ fileSelector = fileSelector,
+ )
+ }
+
+ private fun premiumizeClientIdOrThrow(): String =
+ clientIdProvider().trim().takeIf { it.isNotBlank() }
+ ?: throw IllegalStateException("Premiumize sign-in is missing PREMIUMIZE_CLIENT_ID.")
+}
+
private class RealDebridProviderApi(
private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(),
) : DebridProviderApi {
@@ -245,6 +300,93 @@ private fun buildMagnetUri(resolve: StreamClientResolve): String? {
}
}
+internal fun premiumizeDeviceAuthorizationFromResponse(
+ response: DebridApiResponse,
+ providerId: String,
+): DebridDeviceAuthorization? {
+ val data = response.body?.takeIf { response.isSuccessful } ?: return null
+ val deviceCode = data.deviceCode?.takeIf { it.isNotBlank() } ?: return null
+ val userCode = data.userCode?.takeIf { it.isNotBlank() } ?: return null
+ val verificationUrl = data.verificationUri?.takeIf { it.isNotBlank() } ?: return null
+ return DebridDeviceAuthorization(
+ providerId = providerId,
+ deviceCode = deviceCode,
+ userCode = userCode,
+ verificationUrl = verificationUrl,
+ friendlyVerificationUrl = data.verificationUriComplete?.takeIf { it.isNotBlank() }
+ ?: verificationUrl,
+ intervalSeconds = data.interval?.coerceAtLeast(1) ?: 5,
+ expiresAt = data.expiresIn?.takeIf { it > 0 }?.let { "${it}s" },
+ )
+}
+
+internal fun premiumizeDeviceAuthorizationTokenResult(
+ response: DebridApiResponse,
+): DebridDeviceAuthorizationTokenResult {
+ val body = response.body
+ body?.accessToken?.takeIf { response.isSuccessful && it.isNotBlank() }?.let { accessToken ->
+ return DebridDeviceAuthorizationTokenResult.Authorized(accessToken)
+ }
+ return when (body?.error?.lowercase()) {
+ "authorization_pending", "slow_down" -> DebridDeviceAuthorizationTokenResult.Pending
+ "invalid_grant", "expired_token" -> DebridDeviceAuthorizationTokenResult.Expired
+ "access_denied" -> DebridDeviceAuthorizationTokenResult.Failed(body.errorDescription)
+ else -> {
+ if (response.status == 400 && body?.error.isNullOrBlank()) {
+ DebridDeviceAuthorizationTokenResult.Pending
+ } else {
+ DebridDeviceAuthorizationTokenResult.Failed(body?.errorDescription ?: body?.error ?: response.rawBody)
+ }
+ }
+ }
+}
+
+internal suspend fun resolvePremiumizeDirectDownload(
+ apiKey: String,
+ source: String,
+ resolve: StreamClientResolve,
+ season: Int?,
+ episode: Int?,
+ fallbackFilename: String? = null,
+ fallbackSize: Long? = null,
+ fileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(),
+): DirectDebridResolveResult {
+ val normalizedSource = source.trim().takeIf { it.isNotBlank() } ?: return DirectDebridResolveResult.Stale
+ return try {
+ val response = PremiumizeApiClient.directDownload(apiKey = apiKey, source = normalizedSource)
+ if (!response.isSuccessful) {
+ return when (response.status) {
+ 401, 403 -> DirectDebridResolveResult.Error
+ else -> DirectDebridResolveResult.Stale
+ }
+ }
+ val body = response.body ?: return DirectDebridResolveResult.Stale
+ if (body.status.equals("error", ignoreCase = true)) {
+ val message = listOfNotNull(body.message, body.code).joinToString(" ").lowercase()
+ return if (message.contains("cache") || message.contains("not found")) {
+ DirectDebridResolveResult.NotCached
+ } else {
+ DirectDebridResolveResult.Stale
+ }
+ }
+ val file = fileSelector.selectFile(
+ files = body.content.orEmpty(),
+ resolve = resolve,
+ season = season,
+ episode = episode,
+ ) ?: return DirectDebridResolveResult.Stale
+ val url = file.link?.takeIf { it.isNotBlank() } ?: return DirectDebridResolveResult.Stale
+ DirectDebridResolveResult.Success(
+ url = url,
+ filename = file.displayName().takeIf { it.isNotBlank() } ?: fallbackFilename,
+ videoSize = file.size ?: fallbackSize,
+ )
+ } catch (error: Exception) {
+ if (error is CancellationException) throw error
+ DirectDebridResolveResult.Error
+ }
+}
+
private fun String.toTrackerUrlOrNull(): String? {
val value = trim()
if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt
index 81582aa5..472cdbfa 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt
@@ -6,6 +6,7 @@ data class DebridSettings(
val enabled: Boolean = false,
val cloudLibraryEnabled: Boolean = true,
val providerApiKeys: Map = emptyMap(),
+ val preferredResolverProviderId: String = "",
val instantPlaybackPreparationLimit: Int = 0,
val streamMaxResults: Int = 0,
val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT,
@@ -23,14 +24,29 @@ data class DebridSettings(
val realDebridApiKey: String
get() = apiKeyFor(DebridProviders.REAL_DEBRID_ID)
+ val premiumizeApiKey: String
+ get() = apiKeyFor(DebridProviders.PREMIUMIZE_ID)
+
val hasAnyApiKey: Boolean
get() = DebridProviders.configuredServices(this).isNotEmpty()
+ val resolverServices: List
+ get() = DebridProviders.configuredResolverServices(this)
+
+ val activeResolverCredential: DebridServiceCredential?
+ get() = DebridProviders.preferredResolverService(this)
+
+ val activeResolverProviderId: String?
+ get() = activeResolverCredential?.provider?.id
+
+ val hasResolverProvider: Boolean
+ get() = activeResolverCredential != null
+
val linkResolvingEnabled: Boolean
get() = enabled
val canResolvePlayableLinks: Boolean
- get() = linkResolvingEnabled && hasAnyApiKey
+ get() = linkResolvingEnabled && hasResolverProvider
val hasCloudLibraryProvider: Boolean
get() = DebridProviders.configuredServices(this)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt
index 321fff52..a23c52f6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt
@@ -23,6 +23,7 @@ object DebridSettingsRepository {
private var enabled = false
private var cloudLibraryEnabled = true
private var providerApiKeys = emptyMap()
+ private var preferredResolverProviderId = ""
private var instantPlaybackPreparationLimit = 0
private var streamMaxResults = 0
private var streamSortMode = DebridStreamSortMode.DEFAULT
@@ -50,7 +51,7 @@ object DebridSettingsRepository {
fun setEnabled(value: Boolean) {
ensureLoaded()
- if (value && !hasVisibleApiKey()) return
+ if (value && !hasResolverProvider()) return
if (enabled == value) return
enabled = value
publish()
@@ -63,7 +64,7 @@ object DebridSettingsRepository {
fun setCloudLibraryEnabled(value: Boolean) {
ensureLoaded()
- if (value && !hasVisibleApiKey()) return
+ if (value && !hasCloudLibraryProvider()) return
if (cloudLibraryEnabled == value) return
cloudLibraryEnabled = value
publish()
@@ -80,7 +81,8 @@ object DebridSettingsRepository {
} else {
providerApiKeys + (provider.id to normalized)
}
- disableIfNoKeys()
+ normalizePreferredResolverProviderId(save = true)
+ disableIfNoResolver()
publish()
DebridSettingsStorage.saveProviderApiKey(provider.id, normalized)
}
@@ -93,6 +95,22 @@ object DebridSettingsRepository {
setProviderApiKey(DebridProviders.REAL_DEBRID_ID, value)
}
+ fun setPremiumizeApiKey(value: String) {
+ setProviderApiKey(DebridProviders.PREMIUMIZE_ID, value)
+ }
+
+ fun setPreferredResolverProviderId(providerId: String) {
+ ensureLoaded()
+ val normalized = DebridProviders.byId(providerId)?.id.orEmpty()
+ val next = connectedResolverProviderIds()
+ .firstOrNull { it == normalized }
+ ?: connectedResolverProviderIds().firstOrNull().orEmpty()
+ if (preferredResolverProviderId == next) return
+ preferredResolverProviderId = next
+ publish()
+ DebridSettingsStorage.savePreferredResolverProviderId(next)
+ }
+
fun setInstantPlaybackPreparationLimit(value: Int) {
ensureLoaded()
val normalized = normalizeDebridInstantPlaybackPreparationLimit(value)
@@ -208,18 +226,46 @@ object DebridSettingsRepository {
)
}
- private fun disableIfNoKeys() {
- if (!hasVisibleApiKey()) {
+ private fun disableIfNoResolver() {
+ if (!hasResolverProvider()) {
enabled = false
DebridSettingsStorage.saveEnabled(false)
}
}
- private fun hasVisibleApiKey(): Boolean =
+ private fun hasCloudLibraryProvider(): Boolean =
DebridProviders.visible().any { provider ->
- providerApiKeys[provider.id].orEmpty().isNotBlank()
+ provider.supports(DebridProviderCapability.CloudLibrary) &&
+ providerApiKeys[provider.id].orEmpty().isNotBlank()
}
+ private fun hasResolverProvider(): Boolean = connectedResolverProviderIds().isNotEmpty()
+
+ private fun connectedResolverProviderIds(): List =
+ DebridProviders.visible().filter { provider ->
+ (
+ provider.supports(DebridProviderCapability.ClientResolve) ||
+ provider.supports(DebridProviderCapability.LocalTorrentResolve)
+ ) &&
+ providerApiKeys[provider.id].orEmpty().isNotBlank()
+ }.map { it.id }
+
+ private fun normalizePreferredResolverProviderId(save: Boolean = false) {
+ val providerId = DebridProviders.byId(preferredResolverProviderId)?.id.orEmpty()
+ val connectedResolverIds = connectedResolverProviderIds()
+ val normalized = if (providerId in connectedResolverIds) {
+ providerId
+ } else {
+ connectedResolverIds.firstOrNull().orEmpty()
+ }
+ if (preferredResolverProviderId != normalized) {
+ preferredResolverProviderId = normalized
+ if (save) {
+ DebridSettingsStorage.savePreferredResolverProviderId(normalized)
+ }
+ }
+ }
+
private fun loadFromDisk() {
hasLoaded = true
providerApiKeys = DebridProviders.all()
@@ -230,7 +276,12 @@ object DebridSettingsRepository {
?.let { apiKey -> provider.id to apiKey }
}
.toMap()
- enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey()
+ preferredResolverProviderId = DebridSettingsStorage.loadPreferredResolverProviderId()
+ ?.let(DebridProviders::byId)
+ ?.id
+ .orEmpty()
+ normalizePreferredResolverProviderId(save = true)
+ enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasResolverProvider()
cloudLibraryEnabled = DebridSettingsStorage.loadCloudLibraryEnabled() ?: true
instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit(
DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0,
@@ -281,6 +332,7 @@ object DebridSettingsRepository {
enabled = enabled,
cloudLibraryEnabled = cloudLibraryEnabled,
providerApiKeys = providerApiKeys,
+ preferredResolverProviderId = preferredResolverProviderId,
instantPlaybackPreparationLimit = instantPlaybackPreparationLimit,
streamMaxResults = streamMaxResults,
streamSortMode = streamSortMode,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt
index 31286bb1..7d68db48 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt
@@ -7,6 +7,8 @@ internal expect object DebridSettingsStorage {
fun saveEnabled(enabled: Boolean)
fun loadCloudLibraryEnabled(): Boolean?
fun saveCloudLibraryEnabled(enabled: Boolean)
+ fun loadPreferredResolverProviderId(): String?
+ fun savePreferredResolverProviderId(providerId: String)
fun loadProviderApiKey(providerId: String): String?
fun saveProviderApiKey(providerId: String, apiKey: String)
fun loadTorboxApiKey(): String?
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt
index 85c5cca1..f9eedc46 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt
@@ -10,7 +10,9 @@ object DebridStreamPresentation {
fun apply(groups: List, settings: DebridSettings): List {
if (!settings.canResolvePlayableLinks) return groups
return groups.map { group ->
- val visibleStreams = group.streams.filterNot { stream -> stream.isUncachedDebridStream }
+ val visibleStreams = group.streams
+ .filterNot { stream -> stream.isInactiveResolverStream(settings) }
+ .filterNot { stream -> stream.isUncachedDebridStream }
val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream }
if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams)
@@ -53,6 +55,12 @@ object DebridStreamPresentation {
DebridProviders.byId(debridCacheStatus?.providerId)?.supports(DebridProviderCapability.LocalTorrentCacheCheck) == true &&
debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED
+ private fun StreamItem.isInactiveResolverStream(settings: DebridSettings): Boolean {
+ val streamProviderId = DebridProviders.byId(clientResolve?.service)?.id ?: return false
+ val activeProviderId = settings.activeResolverProviderId ?: return false
+ return isDirectDebridStream && streamProviderId != activeProviderId
+ }
+
private fun applyLimits(
streams: List>,
preferences: DebridStreamPreferences,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt
index d6b6eb8c..73c127fc 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt
@@ -119,7 +119,9 @@ object DirectDebridPlaybackResolver {
return false
}
val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id ?: return false
- return settings.apiKeyFor(providerId).isNotBlank() && DebridProviderApis.apiFor(providerId) != null
+ return providerId == settings.activeResolverProviderId &&
+ settings.apiKeyFor(providerId).isNotBlank() &&
+ DebridProviderApis.apiFor(providerId) != null
}
private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
@@ -128,7 +130,11 @@ object DirectDebridPlaybackResolver {
}
val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id
?: return DirectDebridResolveResult.Error
- val apiKey = DebridSettingsRepository.snapshot()
+ val settings = DebridSettingsRepository.snapshot()
+ if (providerId != settings.activeResolverProviderId) {
+ return DirectDebridResolveResult.Stale
+ }
+ val apiKey = settings
.apiKeyFor(providerId)
.trim()
.takeIf { it.isNotBlank() }
@@ -194,6 +200,7 @@ fun DirectDebridPlayableResult.toastMessage(): String? =
private class LocalDebridAddonStreamResolver(
private val fileSelector: TorboxFileSelector = TorboxFileSelector(),
+ private val premiumizeFileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(),
) {
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val account = localTorrentResolveCredential() ?: return DirectDebridResolveResult.MissingApiKey
@@ -220,6 +227,16 @@ private class LocalDebridAddonStreamResolver(
return when (account.provider.id) {
DebridProviders.TORBOX_ID -> resolveTorbox(stream, resolve, apiKey, magnet, season, episode)
+ DebridProviders.PREMIUMIZE_ID -> resolvePremiumizeDirectDownload(
+ apiKey = apiKey,
+ source = magnet,
+ resolve = resolve,
+ season = season,
+ episode = episode,
+ fallbackFilename = stream.behaviorHints.filename,
+ fallbackSize = stream.behaviorHints.videoSize,
+ fileSelector = premiumizeFileSelector,
+ )
else -> DirectDebridResolveResult.Error
}
}
@@ -273,8 +290,8 @@ private class LocalDebridAddonStreamResolver(
private fun localTorrentResolveCredential(
settings: DebridSettings = DebridSettingsRepository.snapshot(),
): DebridServiceCredential? =
- DebridProviders.configuredServices(settings)
- .firstOrNull { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentResolve) }
+ settings.activeResolverCredential
+ ?.takeIf { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentResolve) }
private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): String? {
val resolve = clientResolve
@@ -294,7 +311,9 @@ private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): Strin
}
resolve ?: return null
val providerId = DebridProviders.byId(resolve.service)?.id ?: return null
- val apiKey = DebridSettingsRepository.snapshot()
+ val settings = DebridSettingsRepository.snapshot()
+ if (providerId != settings.activeResolverProviderId) return null
+ val apiKey = settings
.apiKeyFor(providerId)
.trim()
.takeIf { it.isNotBlank() }
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt
index 0a5c429f..775259b4 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt
@@ -30,7 +30,7 @@ object DirectDebridStreamPreparer {
if (!settings.canResolvePlayableLinks || limit <= 0) return
val candidates = prioritizeCandidates(
- streams = streams,
+ streams = streams.filter(DirectDebridPlaybackResolver::shouldResolveToPlayableStream),
limit = limit,
playerSettings = playerSettings,
installedAddonNames = installedAddonNames,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt
index 2342b198..227a6d37 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt
@@ -82,8 +82,8 @@ object LocalDebridAvailabilityService {
private fun cacheCheckAccount(): DebridServiceCredential? {
val settings = DebridSettingsRepository.snapshot()
if (!settings.canResolvePlayableLinks) return null
- return DebridProviders.configuredServices(settings)
- .firstOrNull { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) }
+ return settings.activeResolverCredential
+ ?.takeIf { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) }
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt
index 4c40e901..59888c7a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt
@@ -1,6 +1,8 @@
package com.nuvio.app.features.debrid
import kotlinx.coroutines.CancellationException
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.longOrNull
internal data class LocalDebridCachedItem(
val name: String?,
@@ -14,6 +16,7 @@ internal object LocalDebridService {
): Map? =
when (account.provider.id) {
DebridProviders.TORBOX_ID -> checkTorboxCached(account.apiKey, hashes)
+ DebridProviders.PREMIUMIZE_ID -> checkPremiumizeCached(account.apiKey, hashes)
else -> null
}
@@ -42,4 +45,38 @@ internal object LocalDebridService {
if (error is CancellationException) throw error
null
}
+
+ private suspend fun checkPremiumizeCached(
+ apiKey: String,
+ hashes: List,
+ ): Map? =
+ try {
+ val normalizedHashes = hashes
+ .map { it.trim().lowercase() }
+ .filter { it.isNotBlank() }
+ .distinct()
+ if (normalizedHashes.isEmpty()) return emptyMap()
+ val sources = normalizedHashes.map { hash -> "magnet:?xt=urn:btih:$hash" }
+ val response = PremiumizeApiClient.checkCache(apiKey = apiKey, items = sources)
+ val body = response.body
+ if (!response.isSuccessful || body?.status.equals("error", ignoreCase = true)) {
+ null
+ } else {
+ normalizedHashes.mapIndexedNotNull { index, hash ->
+ if (body?.response?.getOrNull(index) != true) return@mapIndexedNotNull null
+ hash to LocalDebridCachedItem(
+ name = body.filename?.getOrNull(index),
+ size = body.filesize?.getOrNull(index)?.asLongOrNull(),
+ )
+ }.toMap()
+ }
+ } catch (error: Exception) {
+ if (error is CancellationException) throw error
+ null
+ }
+}
+
+private fun kotlinx.serialization.json.JsonElement?.asLongOrNull(): Long? {
+ val primitive = this as? JsonPrimitive ?: return null
+ return primitive.longOrNull ?: primitive.content.toLongOrNull()
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
index e50ff9f3..244f5f1e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt
@@ -805,6 +805,7 @@ private fun cloudLibraryTypeLabel(type: CloudLibraryItemType): String =
CloudLibraryItemType.Torrent -> stringResource(Res.string.cloud_library_type_torrents)
CloudLibraryItemType.Usenet -> stringResource(Res.string.cloud_library_type_usenet)
CloudLibraryItemType.WebDownload -> stringResource(Res.string.cloud_library_type_web)
+ CloudLibraryItemType.File -> stringResource(Res.string.cloud_library_type_files)
}
private fun formatCloudBytes(bytes: Long): String {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt
index df6a73c2..8a5a23b5 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt
@@ -67,6 +67,7 @@ import com.nuvio.app.features.debrid.DebridStreamSortCriterion
import com.nuvio.app.features.debrid.DebridStreamSortDirection
import com.nuvio.app.features.debrid.DebridStreamSortKey
import com.nuvio.app.features.debrid.DebridStreamVisualTag
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import nuvio.composeapp.generated.resources.Res
@@ -87,6 +88,7 @@ import nuvio.composeapp.generated.resources.settings_debrid_device_auth_connecte
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_missing_configuration
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
@@ -113,6 +115,8 @@ import nuvio.composeapp.generated.resources.settings_debrid_name_template_descri
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_resolve_with
+import nuvio.composeapp.generated.resources.settings_debrid_resolve_with_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
@@ -124,6 +128,9 @@ internal fun LazyListScope.debridSettingsContent(
settings: DebridSettings,
) {
item {
+ var showResolverProviderDialog by rememberSaveable { mutableStateOf(false) }
+ val resolverProviders = settings.resolverServices.map { it.provider }
+ val activeResolverProvider = settings.activeResolverCredential?.provider
SettingsSection(
title = stringResource(Res.string.settings_debrid_section_title),
isTablet = isTablet,
@@ -147,11 +154,22 @@ internal fun LazyListScope.debridSettingsContent(
title = stringResource(Res.string.settings_debrid_enable),
description = stringResource(Res.string.settings_debrid_enable_description),
checked = settings.canResolvePlayableLinks,
- enabled = settings.hasAnyApiKey,
+ enabled = settings.hasResolverProvider,
isTablet = isTablet,
onCheckedChange = DebridSettingsRepository::setLinkResolvingEnabled,
)
- if (!settings.hasAnyApiKey) {
+ if (settings.canResolvePlayableLinks && resolverProviders.size > 1 && activeResolverProvider != null) {
+ SettingsGroupDivider(isTablet = isTablet)
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = stringResource(Res.string.settings_debrid_resolve_with),
+ description = stringResource(Res.string.settings_debrid_resolve_with_description),
+ value = activeResolverProvider.displayName,
+ enabled = true,
+ onClick = { showResolverProviderDialog = true },
+ )
+ }
+ if (!settings.hasResolverProvider) {
SettingsGroupDivider(isTablet = isTablet)
DebridInfoRow(
isTablet = isTablet,
@@ -160,6 +178,17 @@ internal fun LazyListScope.debridSettingsContent(
}
}
}
+
+ if (showResolverProviderDialog && resolverProviders.size > 1 && activeResolverProvider != null) {
+ DebridSingleChoiceDialog(
+ title = stringResource(Res.string.settings_debrid_resolve_with),
+ selectedValue = activeResolverProvider,
+ options = resolverProviders,
+ label = { provider -> provider.displayName },
+ onSelected = { provider -> DebridSettingsRepository.setPreferredResolverProviderId(provider.id) },
+ onDismiss = { showResolverProviderDialog = false },
+ )
+ }
}
item {
@@ -1270,6 +1299,7 @@ private fun DebridDeviceAuthDialog(
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 missingConfigurationMessage = stringResource(Res.string.settings_debrid_device_auth_missing_configuration)
val expiredMessage = stringResource(Res.string.settings_debrid_device_auth_expired)
val codeCopiedMessage = stringResource(Res.string.settings_debrid_device_auth_code_copied)
@@ -1284,11 +1314,20 @@ private fun DebridDeviceAuthDialog(
isStarting = true
isPolling = false
statusMessage = null
- session = runCatching {
+ val startResult = runCatching {
DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio")
- }.getOrNull()
+ }.onFailure { error ->
+ if (error is CancellationException) throw error
+ }
+ session = startResult.getOrNull()
isStarting = false
- statusMessage = if (session == null) failedMessage else waitingMessage
+ statusMessage = if (session == null) {
+ startResult.exceptionOrNull()?.message?.takeIf { it.contains("PREMIUMIZE_CLIENT_ID") }
+ ?.let { missingConfigurationMessage }
+ ?: failedMessage
+ } else {
+ waitingMessage
+ }
}
LaunchedEffect(session?.deviceCode, restartNonce, isConnected) {
@@ -1301,8 +1340,13 @@ private fun DebridDeviceAuthDialog(
DebridProviderApis.apiFor(provider.id)
?.redeemDeviceAuthorization(activeSession.deviceCode)
?: DebridDeviceAuthorizationTokenResult.Unsupported
- }.getOrElse {
- DebridDeviceAuthorizationTokenResult.Failed(it.message)
+ }.getOrElse { error ->
+ if (error is CancellationException) throw error
+ if (error.isCancelledHttpRequest()) {
+ DebridDeviceAuthorizationTokenResult.Pending
+ } else {
+ DebridDeviceAuthorizationTokenResult.Failed(null)
+ }
}
isPolling = false
when (result) {
@@ -1321,7 +1365,11 @@ private fun DebridDeviceAuthDialog(
return@LaunchedEffect
}
- is DebridDeviceAuthorizationTokenResult.Failed,
+ is DebridDeviceAuthorizationTokenResult.Failed -> {
+ statusMessage = result.message.toDeviceAuthStatusMessage(failedMessage)
+ return@LaunchedEffect
+ }
+
DebridDeviceAuthorizationTokenResult.Unsupported -> {
statusMessage = failedMessage
return@LaunchedEffect
@@ -1401,7 +1449,7 @@ private fun DebridDeviceAuthDialog(
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
- color = if (message == failedMessage || message == expiredMessage) {
+ color = if (message == failedMessage || message == expiredMessage || message == missingConfigurationMessage) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -1448,6 +1496,29 @@ private fun DebridDeviceAuthDialog(
}
}
+private fun Throwable.isCancelledHttpRequest(): Boolean {
+ val text = listOfNotNull(message, toString())
+ .joinToString(" ")
+ .lowercase()
+ return "code=-999" in text ||
+ ("nsurlerrordomain" in text && ("cancelled" in text || "canceled" in text))
+}
+
+private fun String?.toDeviceAuthStatusMessage(fallback: String): String {
+ val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return fallback
+ val lower = value.lowercase()
+ return if (
+ value.length > 180 ||
+ "exception in http request" in lower ||
+ "nsurlerrordomain" in lower ||
+ "userinfo=" in lower
+ ) {
+ fallback
+ } else {
+ value
+ }
+}
+
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun DebridApiKeyDialog(
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt
new file mode 100644
index 00000000..9e08f5a1
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt
@@ -0,0 +1,67 @@
+package com.nuvio.app.features.cloud
+
+import com.nuvio.app.features.debrid.DebridProviders
+import com.nuvio.app.features.debrid.PremiumizeCloudFileDto
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class PremiumizeCloudLibraryProviderApiTest {
+ @Test
+ fun `groups nested files by top-level folder and keeps root files standalone`() {
+ val items = premiumizeCloudItemsFromFiles(
+ files = listOf(
+ PremiumizeCloudFileDto(
+ id = "e01",
+ name = "Show.S01E01.mkv",
+ path = "Show/Season 01/Show.S01E01.mkv",
+ size = 1_000,
+ mimeType = "video/x-matroska",
+ link = "https://pm/e01",
+ ),
+ PremiumizeCloudFileDto(
+ id = "e02",
+ name = "Show.S01E02.mkv",
+ path = "Show/Season 01/Show.S01E02.mkv",
+ size = 2_000,
+ mimeType = "video/x-matroska",
+ link = "https://pm/e02",
+ ),
+ PremiumizeCloudFileDto(
+ id = "movie",
+ name = "Movie.mp4",
+ path = "Movie.mp4",
+ size = 3_000,
+ mimeType = "video/mp4",
+ link = "https://pm/movie",
+ ),
+ ),
+ providerId = DebridProviders.PREMIUMIZE_ID,
+ providerName = "Premiumize",
+ )
+
+ assertEquals(listOf("Movie.mp4", "Show"), items.map { it.name })
+ assertEquals(CloudLibraryItemType.File, items.first().type)
+ assertEquals(listOf("Show.S01E01.mkv", "Show.S01E02.mkv"), items[1].files.map { it.name })
+ assertEquals("https://pm/e01", items[1].files.first().playbackUrl)
+ }
+
+ @Test
+ fun `marks non video and missing fields as non playable without dropping valid files`() {
+ val items = premiumizeCloudItemsFromFiles(
+ files = listOf(
+ PremiumizeCloudFileDto(id = "notes", name = "notes.txt", path = "Pack/notes.txt", size = 100),
+ PremiumizeCloudFileDto(id = "video", name = "video.avi", path = "Pack/video.avi", size = 200),
+ PremiumizeCloudFileDto(id = "missing", name = null, path = null, size = 300),
+ ),
+ providerId = DebridProviders.PREMIUMIZE_ID,
+ providerName = "Premiumize",
+ )
+
+ assertEquals(1, items.size)
+ assertEquals(2, items.single().files.size)
+ assertFalse(items.single().files.first { it.name == "notes.txt" }.playable)
+ assertTrue(items.single().files.first { it.name == "video.avi" }.playable)
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt
index ad4f9eab..04159a21 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt
@@ -127,6 +127,42 @@ class DebridFileSelectorTest {
assertEquals(1, selected?.id)
}
+ @Test
+ fun `Premiumize direct download selector ignores non-video and matches episode`() {
+ val files = listOf(
+ PremiumizeDirectDownloadFileDto(path = "Show/Readme.txt", size = 9_000, link = "https://pm/readme"),
+ PremiumizeDirectDownloadFileDto(path = "Show/Show.S01E02.mkv", size = 2_000, link = "https://pm/e02"),
+ PremiumizeDirectDownloadFileDto(path = "Show/Show.S01E01.mkv", size = 1_000, link = "https://pm/e01"),
+ )
+
+ val selected = PremiumizeDirectDownloadFileSelector().selectFile(
+ files = files,
+ resolve = resolve(season = 1, episode = 1),
+ season = null,
+ episode = null,
+ )
+
+ assertEquals("Show/Show.S01E01.mkv", selected?.path)
+ }
+
+ @Test
+ fun `Premiumize direct download selector falls back to largest playable file`() {
+ val files = listOf(
+ PremiumizeDirectDownloadFileDto(path = "small.mp4", size = 1_000, link = "https://pm/small"),
+ PremiumizeDirectDownloadFileDto(path = "large.mkv", size = 3_000, link = "https://pm/large"),
+ PremiumizeDirectDownloadFileDto(path = "large-without-link.mkv", size = 9_000, link = null),
+ )
+
+ val selected = PremiumizeDirectDownloadFileSelector().selectFile(
+ files = files,
+ resolve = resolve(),
+ season = null,
+ episode = null,
+ )
+
+ assertEquals("large.mkv", selected?.path)
+ }
+
private fun resolve(
fileIdx: Int? = null,
season: Int? = null,
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt
index 31c65109..ce8a66e7 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt
@@ -14,6 +14,16 @@ class DebridProviderTest {
assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.CloudLibrary))
}
+ @Test
+ fun `premiumize exposes oauth and cloud service capabilities`() {
+ assertTrue(DebridProviders.Premiumize.visibleInUi)
+ assertTrue(DebridProviders.Premiumize.authMethod == DebridProviderAuthMethod.DeviceCode)
+ assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.ClientResolve))
+ assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.LocalTorrentCacheCheck))
+ assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.LocalTorrentResolve))
+ assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.CloudLibrary))
+ }
+
@Test
fun `real debrid stays hidden from local addon capability paths`() {
assertTrue(DebridProviders.RealDebrid.authMethod == DebridProviderAuthMethod.ApiKey)
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt
index 8aa924d6..dbe4dd26 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt
@@ -22,18 +22,36 @@ class DebridSettingsTest {
val settings = DebridSettings(
providerApiKeys = mapOf(
DebridProviders.TORBOX_ID to "tb_key",
+ DebridProviders.PREMIUMIZE_ID to "pm_key",
DebridProviders.REAL_DEBRID_ID to "rd_key",
),
)
val services = DebridProviders.configuredServices(settings)
- assertEquals(listOf(DebridProviders.TORBOX_ID), services.map { it.provider.id })
- assertEquals("tb_key", services.single().apiKey)
+ assertEquals(listOf(DebridProviders.TORBOX_ID, DebridProviders.PREMIUMIZE_ID), services.map { it.provider.id })
+ assertEquals(listOf("tb_key", "pm_key"), services.map { it.apiKey })
assertTrue(settings.hasAnyApiKey)
assertFalse(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID))
}
+ @Test
+ fun `preferred resolver uses saved provider when connected and falls back otherwise`() {
+ val preferred = DebridSettings(
+ enabled = true,
+ providerApiKeys = mapOf(
+ DebridProviders.TORBOX_ID to "tb_key",
+ DebridProviders.PREMIUMIZE_ID to "pm_key",
+ ),
+ preferredResolverProviderId = DebridProviders.PREMIUMIZE_ID,
+ )
+ val fallback = preferred.copy(preferredResolverProviderId = DebridProviders.REAL_DEBRID_ID)
+
+ assertEquals(DebridProviders.PREMIUMIZE_ID, preferred.activeResolverProviderId)
+ assertEquals(DebridProviders.TORBOX_ID, fallback.activeResolverProviderId)
+ assertTrue(preferred.canResolvePlayableLinks)
+ }
+
@Test
fun `cloud library and link resolving capabilities are independent`() {
val settings = DebridSettings(
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/PremiumizeDeviceAuthTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/PremiumizeDeviceAuthTest.kt
new file mode 100644
index 00000000..b4b87e4a
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/PremiumizeDeviceAuthTest.kt
@@ -0,0 +1,62 @@
+package com.nuvio.app.features.debrid
+
+import kotlinx.coroutines.runBlocking
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class PremiumizeDeviceAuthTest {
+ @Test
+ fun `maps pending and slow down oauth states to pending`() {
+ assertEquals(
+ DebridDeviceAuthorizationTokenResult.Pending,
+ premiumizeDeviceAuthorizationTokenResult(tokenError("authorization_pending")),
+ )
+ assertEquals(
+ DebridDeviceAuthorizationTokenResult.Pending,
+ premiumizeDeviceAuthorizationTokenResult(tokenError("slow_down")),
+ )
+ }
+
+ @Test
+ fun `maps success expired denied and invalid oauth states`() {
+ assertTrue(
+ premiumizeDeviceAuthorizationTokenResult(
+ DebridApiResponse(
+ status = 200,
+ body = PremiumizeDeviceTokenDto(accessToken = "pm-token", tokenType = "Bearer"),
+ rawBody = "",
+ ),
+ ) is DebridDeviceAuthorizationTokenResult.Authorized,
+ )
+ assertEquals(
+ DebridDeviceAuthorizationTokenResult.Expired,
+ premiumizeDeviceAuthorizationTokenResult(tokenError("invalid_grant")),
+ )
+ assertTrue(
+ premiumizeDeviceAuthorizationTokenResult(tokenError("access_denied")) is
+ DebridDeviceAuthorizationTokenResult.Failed,
+ )
+ }
+
+ @Test
+ fun `missing Premiumize client id fails before device flow starts`() = runBlocking {
+ val api = PremiumizeDebridProviderApi(clientIdProvider = { "" })
+
+ val failed = try {
+ api.startDeviceAuthorization("Nuvio")
+ false
+ } catch (_: IllegalStateException) {
+ true
+ }
+
+ assertTrue(failed)
+ }
+
+ private fun tokenError(error: String): DebridApiResponse =
+ DebridApiResponse(
+ status = 400,
+ body = PremiumizeDeviceTokenDto(error = error, errorDescription = error),
+ rawBody = """{"error":"$error"}""",
+ )
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt
index 311bba69..d11d9c64 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt
@@ -15,6 +15,7 @@ import platform.Foundation.NSUserDefaults
actual object DebridSettingsStorage {
private const val enabledKey = "debrid_enabled"
private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled"
+ private const val preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id"
private const val torboxApiKeyKey = "debrid_torbox_api_key"
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
@@ -31,6 +32,7 @@ actual object DebridSettingsStorage {
listOf(
enabledKey,
cloudLibraryEnabledKey,
+ preferredResolverProviderIdKey,
instantPlaybackPreparationLimitKey,
streamMaxResultsKey,
streamSortModeKey,
@@ -55,6 +57,12 @@ actual object DebridSettingsStorage {
saveBoolean(cloudLibraryEnabledKey, enabled)
}
+ actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey)
+
+ actual fun savePreferredResolverProviderId(providerId: String) {
+ saveString(preferredResolverProviderIdKey, providerId)
+ }
+
actual fun loadProviderApiKey(providerId: String): String? =
loadString(providerApiKeyKey(providerId))
@@ -172,6 +180,7 @@ actual object DebridSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) }
+ loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) }
DebridProviders.all().forEach { provider ->
loadProviderApiKey(provider.id)?.let {
put(providerApiKeyKey(provider.id), encodeSyncString(it))
@@ -196,6 +205,7 @@ actual object DebridSettingsStorage {
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled)
+ payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId)
DebridProviders.all().forEach { provider ->
payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey ->
saveProviderApiKey(provider.id, apiKey)