mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-23 02:02:04 +00:00
feat(cloud): adding premimize
This commit is contained in:
parent
cf1e162eb1
commit
8c25cca724
29 changed files with 1132 additions and 31 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -595,6 +595,8 @@
|
|||
<string name="settings_debrid_cloud_library_description">Bla gjennom og spill filer som allerede finnes i tilkoblede skytjenester.</string>
|
||||
<string name="settings_debrid_enable">Løs spillbare lenker</string>
|
||||
<string name="settings_debrid_enable_description">Be en tilkoblet tjeneste om spillbare lenker når et resultat trenger det. Dette kan legge elementet til i den tjenesten.</string>
|
||||
<string name="settings_debrid_resolve_with">Løs med</string>
|
||||
<string name="settings_debrid_resolve_with_description">Velg hvilken tilkoblet skytjeneste som håndterer spillbare lenker.</string>
|
||||
<string name="settings_debrid_add_key_first">Koble til en skytjenestekonto først.</string>
|
||||
<string name="settings_debrid_section_providers">Skytjenester</string>
|
||||
<string name="settings_debrid_provider_description">Koble til %1$s-kontoen din.</string>
|
||||
|
|
@ -613,6 +615,7 @@
|
|||
<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_missing_configuration">Denne innloggingsmetoden er ikke konfigurert i denne versjonen.</string>
|
||||
<string name="settings_debrid_device_auth_expired">Denne koden er utløpt. Prøv igjen.</string>
|
||||
<string name="settings_debrid_section_instant_playback">Lenkeforberedelse</string>
|
||||
<string name="settings_debrid_prepare_instant_playback">Forbered lenker</string>
|
||||
|
|
|
|||
|
|
@ -596,6 +596,8 @@
|
|||
<string name="settings_debrid_cloud_library_description">Browse and play files already in your connected cloud services.</string>
|
||||
<string name="settings_debrid_enable">Resolve playable links</string>
|
||||
<string name="settings_debrid_enable_description">Ask a connected service for playable links when a result needs it. This may add the item to that service.</string>
|
||||
<string name="settings_debrid_resolve_with">Resolve with</string>
|
||||
<string name="settings_debrid_resolve_with_description">Choose which connected cloud service manages playable links.</string>
|
||||
<string name="settings_debrid_add_key_first">Connect a cloud service account first.</string>
|
||||
<string name="settings_debrid_section_providers">Cloud Services</string>
|
||||
<string name="settings_debrid_provider_description">Connect your %1$s account.</string>
|
||||
|
|
@ -615,6 +617,7 @@
|
|||
<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_missing_configuration">This sign-in method is not configured in this build.</string>
|
||||
<string name="settings_debrid_device_auth_expired">This code expired. Try again.</string>
|
||||
<string name="settings_debrid_section_instant_playback">Link Preparation</string>
|
||||
<string name="settings_debrid_prepare_instant_playback">Prepare links</string>
|
||||
|
|
@ -1330,7 +1333,7 @@
|
|||
<string name="library_trakt_load_failed">Couldn't load Trakt library</string>
|
||||
<string name="library_trakt_title">Trakt Library</string>
|
||||
<string name="cloud_library_connect_action">Connect account</string>
|
||||
<string name="cloud_library_connect_message">Connect Torbox in Cloud Services settings to browse playable files from your cloud library.</string>
|
||||
<string name="cloud_library_connect_message">Connect a cloud service in Cloud Services settings to browse playable files from your cloud library.</string>
|
||||
<string name="cloud_library_connect_title">No cloud account connected</string>
|
||||
<string name="cloud_library_disabled_action">Open Cloud Services</string>
|
||||
<string name="cloud_library_disabled_message">Turn on Cloud library in Cloud Services settings to browse files from connected accounts.</string>
|
||||
|
|
@ -1352,6 +1355,7 @@
|
|||
<string name="cloud_library_type_torrents">Torrents</string>
|
||||
<string name="cloud_library_type_usenet">Usenet</string>
|
||||
<string name="cloud_library_type_web">Web</string>
|
||||
<string name="cloud_library_type_files">Files</string>
|
||||
<string name="media_anime">Anime</string>
|
||||
<string name="media_channels">Channels</string>
|
||||
<string name="media_movies">Movies</string>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ internal interface CloudLibraryProviderApi {
|
|||
internal object CloudLibraryProviderApis {
|
||||
private val registered = listOf(
|
||||
TorboxCloudLibraryProviderApi(),
|
||||
PremiumizeCloudLibraryProviderApi(),
|
||||
)
|
||||
|
||||
fun all(): List<CloudLibraryProviderApi> = registered
|
||||
|
|
|
|||
|
|
@ -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<List<CloudLibraryItem>> =
|
||||
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<PremiumizeCloudFileDto>,
|
||||
providerId: String,
|
||||
providerName: String,
|
||||
): List<CloudLibraryItem> {
|
||||
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<CloudLibraryFile> { !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",
|
||||
)
|
||||
|
|
@ -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<String?>.firstNonBlank(): String? =
|
||||
|
|
|
|||
|
|
@ -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<PremiumizeDeviceAuthorizationDto> =
|
||||
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<PremiumizeDeviceTokenDto> =
|
||||
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<PremiumizeAccountInfoDto> =
|
||||
request(
|
||||
method = "GET",
|
||||
url = "$BASE_URL/api/account/info",
|
||||
apiKey = apiKey,
|
||||
)
|
||||
|
||||
suspend fun listAllItems(apiKey: String): DebridApiResponse<PremiumizeItemListAllDto> =
|
||||
request(
|
||||
method = "GET",
|
||||
url = "$BASE_URL/api/item/listall",
|
||||
apiKey = apiKey,
|
||||
)
|
||||
|
||||
suspend fun itemDetails(
|
||||
apiKey: String,
|
||||
itemId: String,
|
||||
): DebridApiResponse<PremiumizeItemDetailsDto> =
|
||||
request(
|
||||
method = "GET",
|
||||
url = "$BASE_URL/api/item/details?${queryString("id" to itemId)}",
|
||||
apiKey = apiKey,
|
||||
)
|
||||
|
||||
suspend fun directDownload(
|
||||
apiKey: String,
|
||||
source: String,
|
||||
): DebridApiResponse<PremiumizeDirectDownloadDto> =
|
||||
formRequest(
|
||||
method = "POST",
|
||||
url = "$BASE_URL/api/transfer/directdl",
|
||||
apiKey = apiKey,
|
||||
fields = listOf("src" to source),
|
||||
)
|
||||
|
||||
suspend fun checkCache(
|
||||
apiKey: String,
|
||||
items: List<String>,
|
||||
): DebridApiResponse<PremiumizeCacheCheckDto> {
|
||||
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 <reified T> formRequestWithoutAuth(
|
||||
method: String,
|
||||
url: String,
|
||||
fields: List<Pair<String, String>>,
|
||||
): DebridApiResponse<T> =
|
||||
requestWithoutAuth(
|
||||
method = method,
|
||||
url = url,
|
||||
body = formBody(fields),
|
||||
contentType = "application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
private suspend inline fun <reified T> formRequest(
|
||||
method: String,
|
||||
url: String,
|
||||
apiKey: String,
|
||||
fields: List<Pair<String, String>>,
|
||||
): DebridApiResponse<T> =
|
||||
request(
|
||||
method = method,
|
||||
url = url,
|
||||
apiKey = apiKey,
|
||||
body = formBody(fields),
|
||||
contentType = "application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
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 suspend inline fun <reified T> request(
|
||||
method: String,
|
||||
url: String,
|
||||
apiKey: String,
|
||||
body: String = "",
|
||||
contentType: String? = null,
|
||||
): DebridApiResponse<T> {
|
||||
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<T>(),
|
||||
rawBody = response.body,
|
||||
)
|
||||
}
|
||||
|
||||
private fun formBody(fields: List<Pair<String, String>>): String =
|
||||
fields.joinToString("&") { (key, value) ->
|
||||
"${encodeFormValue(key)}=${encodeFormValue(value)}"
|
||||
}
|
||||
|
||||
private fun authHeaders(apiKey: String): Map<String, String> =
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<PremiumizeDirectDownloadFileDto>? = 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<Boolean>? = null,
|
||||
val filename: List<String?>? = null,
|
||||
val filesize: List<JsonElement?>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class PremiumizeItemListAllDto(
|
||||
val status: String? = null,
|
||||
val message: String? = null,
|
||||
val code: String? = null,
|
||||
val files: List<PremiumizeCloudFileDto>? = 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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -107,6 +107,57 @@ internal class RealDebridFileSelector {
|
|||
displayName().lowercase().hasVideoExtension()
|
||||
}
|
||||
|
||||
internal class PremiumizeDirectDownloadFileSelector {
|
||||
fun selectFile(
|
||||
files: List<PremiumizeDirectDownloadFileDto>,
|
||||
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('.')
|
||||
|
|
|
|||
|
|
@ -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<DebridProvider> = registered
|
||||
|
||||
|
|
@ -85,6 +99,19 @@ object DebridProviders {
|
|||
?.let { apiKey -> DebridServiceCredential(provider, apiKey) }
|
||||
}
|
||||
|
||||
fun configuredResolverServices(settings: DebridSettings): List<DebridServiceCredential> =
|
||||
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<String> =
|
||||
configuredServices(settings).map { instantName(it.provider.id) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PremiumizeDeviceAuthorizationDto>,
|
||||
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<PremiumizeDeviceTokenDto>,
|
||||
): 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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ data class DebridSettings(
|
|||
val enabled: Boolean = false,
|
||||
val cloudLibraryEnabled: Boolean = true,
|
||||
val providerApiKeys: Map<String, String> = 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<DebridServiceCredential>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ object DebridSettingsRepository {
|
|||
private var enabled = false
|
||||
private var cloudLibraryEnabled = true
|
||||
private var providerApiKeys = emptyMap<String, String>()
|
||||
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<String> =
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ object DebridStreamPresentation {
|
|||
fun apply(groups: List<AddonStreamGroup>, settings: DebridSettings): List<AddonStreamGroup> {
|
||||
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<Pair<StreamItem, DebridStreamFacts>>,
|
||||
preferences: DebridStreamPreferences,
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, LocalDebridCachedItem>? =
|
||||
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<String>,
|
||||
): Map<String, LocalDebridCachedItem>? =
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<PremiumizeDeviceTokenDto> =
|
||||
DebridApiResponse(
|
||||
status = 400,
|
||||
body = PremiumizeDeviceTokenDto(error = error, errorDescription = error),
|
||||
rawBody = """{"error":"$error"}""",
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue