Merge PR #1331: fix Torbox cloud library on Android

This commit is contained in:
tapframe 2026-07-18 03:41:12 +05:30
commit 78b826fdf7
7 changed files with 196 additions and 26 deletions

View file

@ -90,8 +90,11 @@ private val addonHttpClient = OkHttpClient.Builder()
.build()
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
private const val maxRawResponseBodyBytes = 1024 * 1024
private const val truncationSuffix = "\n...[truncated]"
private data class LimitedReadResult(
val bytes: ByteArray,
val truncated: Boolean,
)
private fun requestAllowsBody(method: String): Boolean =
when (method.uppercase()) {
@ -107,11 +110,6 @@ private fun Map<String, String>.withoutAcceptEncoding(): Map<String, String> =
private fun Map<String, String>.getHeaderIgnoreCase(name: String): String? =
entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value
private data class LimitedReadResult(
val bytes: ByteArray,
val truncated: Boolean,
)
private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResult {
val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024))
val buffer = ByteArray(8 * 1024)
@ -132,11 +130,11 @@ private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResu
return LimitedReadResult(out.toByteArray(), truncated)
}
private fun readResponseBodyLimited(body: ResponseBody?): String {
private fun readResponseBodyLimited(body: ResponseBody?, maxBytes: Int): String {
if (body == null) return ""
val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
val readResult = body.byteStream().use { stream ->
readAtMostBytes(stream, maxRawResponseBodyBytes)
readAtMostBytes(stream, maxBytes.coerceAtLeast(0))
}
val decoded = try {
@ -145,11 +143,7 @@ private fun readResponseBodyLimited(body: ResponseBody?): String {
String(readResult.bytes, Charsets.UTF_8)
}
return if (readResult.truncated) {
decoded + truncationSuffix
} else {
decoded
}
return if (readResult.truncated) "$decoded\n...[truncated]" else decoded
}
private fun readResponseBody(body: ResponseBody?): String {
@ -247,6 +241,7 @@ actual suspend fun httpRequestRaw(
headers: Map<String, String>,
body: String,
followRedirects: Boolean,
maxResponseBodyBytes: Int,
): RawHttpResponse =
withContext(Dispatchers.IO) {
val normalizedMethod = method.uppercase()
@ -279,7 +274,7 @@ actual suspend fun httpRequestRaw(
status = response.code,
statusText = response.message,
url = response.request.url.toString(),
body = readResponseBodyLimited(response.body),
body = readResponseBodyLimited(response.body, maxResponseBodyBytes),
headers = response.headers.toMultimap().mapValues { (_, values) ->
values.joinToString(",")
}.mapKeys { (name, _) ->

View file

@ -15,6 +15,9 @@ data class RawHttpResponse(
val headers: Map<String, String>,
)
/** Default safety limit for generic and plugin-provided HTTP responses. */
internal const val DefaultRawHttpResponseMaxBytes = 1024 * 1024
expect suspend fun httpGetText(url: String): String
expect suspend fun httpPostJson(url: String, body: String): String
@ -36,4 +39,5 @@ expect suspend fun httpRequestRaw(
headers: Map<String, String>,
body: String,
followRedirects: Boolean = true,
maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes,
): RawHttpResponse

View file

@ -62,17 +62,35 @@ internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi {
private fun com.nuvio.app.features.debrid.DebridApiResponse<com.nuvio.app.features.debrid.TorboxEnvelopeDto<List<TorboxCloudItemDto>>>.itemsOrThrow(
type: CloudLibraryItemType,
): List<CloudLibraryItem> {
if (!isSuccessful || body?.success == false) {
throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() })
}
return body?.data.orEmpty().mapNotNull { dto ->
dto.toCloudLibraryItem(
providerId = provider.id,
providerName = provider.displayName,
type = type,
)
}
): List<CloudLibraryItem> =
toCloudLibraryItemsOrThrow(
providerId = provider.id,
providerName = provider.displayName,
type = type,
)
}
internal fun com.nuvio.app.features.debrid.DebridApiResponse<com.nuvio.app.features.debrid.TorboxEnvelopeDto<List<TorboxCloudItemDto>>>.toCloudLibraryItemsOrThrow(
providerId: String,
providerName: String,
type: CloudLibraryItemType,
): List<CloudLibraryItem> {
if (!isSuccessful || body?.success == false) {
throw IllegalStateException(
body?.detail
?: body?.error
?: rawBody.takeIf { it.isNotBlank() }
?: "Torbox request failed (HTTP $status).",
)
}
val envelope = body
?: throw IllegalStateException("Unexpected response from Torbox (HTTP $status).")
return envelope.data.orEmpty().mapNotNull { dto ->
dto.toCloudLibraryItem(
providerId = providerId,
providerName = providerName,
type = type,
)
}
}

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.addons.RawHttpResponse
import com.nuvio.app.features.addons.DefaultRawHttpResponseMaxBytes
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
@ -27,6 +28,8 @@ internal object DebridApiJson {
internal object TorboxApiClient {
private const val BASE_URL = "https://api.torbox.app"
// Torbox returns up to 1,000 items, each with its full files array.
private const val cloudListResponseMaxBytes = 16 * 1024 * 1024
suspend fun startDeviceAuthorization(
appName: String,
@ -120,6 +123,7 @@ internal object TorboxApiClient {
method = "GET",
url = "$BASE_URL/v1/api/torrents/mylist",
apiKey = apiKey,
maxResponseBodyBytes = cloudListResponseMaxBytes,
)
suspend fun listCloudUsenet(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
@ -127,6 +131,7 @@ internal object TorboxApiClient {
method = "GET",
url = "$BASE_URL/v1/api/usenet/mylist",
apiKey = apiKey,
maxResponseBodyBytes = cloudListResponseMaxBytes,
)
suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
@ -134,6 +139,7 @@ internal object TorboxApiClient {
method = "GET",
url = "$BASE_URL/v1/api/webdl/mylist",
apiKey = apiKey,
maxResponseBodyBytes = cloudListResponseMaxBytes,
)
suspend fun requestDownloadLink(
@ -222,6 +228,7 @@ internal object TorboxApiClient {
apiKey: String,
body: String = "",
contentType: String? = null,
maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes,
): DebridApiResponse<T> {
val headers = authHeaders(apiKey) + listOfNotNull(
contentType?.let { "Content-Type" to it },
@ -232,6 +239,7 @@ internal object TorboxApiClient {
url = url,
headers = headers,
body = body,
maxResponseBodyBytes = maxResponseBodyBytes,
)
return DebridApiResponse(
status = response.status,

View file

@ -0,0 +1,80 @@
package com.nuvio.app.features.cloud
internal val torboxRealMylistPayload = """
{
"success": true,
"error": null,
"detail": "Torrent list retrieved successfully.",
"data": [
{
"id": 123456,
"auth_id": "user-uuid",
"server": 7,
"hash": "2c229180e129280a36ba7f3a22e2f5135a02a766",
"name": "Show.S01.1080p.WEB-DL",
"magnet": "magnet:?xt=urn:btih:2c229180e129280a36ba7f3a22e2f5135a02a766",
"size": 4294967296,
"active": false,
"created_at": "2026-03-08T21:21:28Z",
"updated_at": "2026-03-08T21:21:41Z",
"download_state": "completed",
"seeds": 12,
"peers": 3,
"ratio": 1.5,
"progress": 1,
"download_speed": 0,
"upload_speed": 0,
"eta": 0,
"torrent_file": true,
"expires_at": "2026-04-07T21:21:41Z",
"download_present": true,
"files": [
{
"id": 0,
"md5": null,
"hash": "2c229180e129280a36ba7f3a22e2f5135a02a766",
"name": "Show.S01.1080p.WEB-DL/Show.S01E01.1080p.WEB-DL.mkv",
"size": 2147483648,
"zipped": false,
"s3_path": "buckets/123456/Show.S01E01.1080p.WEB-DL.mkv",
"infected": false,
"mimetype": "video/x-matroska",
"short_name": "Show.S01E01.1080p.WEB-DL.mkv",
"absolute_path": "/completed/Show.S01.1080p.WEB-DL/Show.S01E01.1080p.WEB-DL.mkv",
"opensubtitles_hash": "abc"
},
{
"id": 1,
"md5": null,
"hash": "2c229180e129280a36ba7f3a22e2f5135a02a766",
"name": "Show.S01.1080p.WEB-DL/sample.txt",
"size": 1024,
"zipped": false,
"s3_path": "buckets/123456/sample.txt",
"infected": false,
"mimetype": "text/plain",
"short_name": "sample.txt",
"absolute_path": "/completed/Show.S01.1080p.WEB-DL/sample.txt",
"opensubtitles_hash": null
}
],
"download_path": "123456",
"availability": 1,
"download_finished": true,
"tracker": null,
"total_uploaded": 0,
"total_downloaded": 4294967296,
"cached": true,
"owner": "user-uuid",
"seed_torrent": false,
"allow_zipped": true,
"long_term_seeding": false,
"tracker_message": null,
"cached_at": "2026-04-07T21:21:41Z",
"private": false,
"alternative_hashes": [],
"tags": []
}
]
}
""".trimIndent()

View file

@ -1,11 +1,15 @@
package com.nuvio.app.features.cloud
import com.nuvio.app.features.debrid.DebridApiJson
import com.nuvio.app.features.debrid.DebridApiResponse
import com.nuvio.app.features.debrid.DebridProviders
import com.nuvio.app.features.debrid.TorboxCloudFileDto
import com.nuvio.app.features.debrid.TorboxCloudItemDto
import com.nuvio.app.features.debrid.TorboxEnvelopeDto
import kotlinx.serialization.json.JsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@ -190,4 +194,64 @@ class TorboxCloudLibraryProviderApiTest {
assertEquals("usenet_id", torboxRequestIdParameterName(CloudLibraryItemType.Usenet))
assertEquals("web_id", torboxRequestIdParameterName(CloudLibraryItemType.WebDownload))
}
@Test
fun `decodes real Torbox mylist payload and maps playable items`() {
val items = mylistResponse(rawBody = torboxRealMylistPayload).toCloudLibraryItemsOrThrow(
providerId = "torbox",
providerName = "Torbox",
type = CloudLibraryItemType.Torrent,
)
assertEquals(1, items.size)
val item = items.single()
assertEquals("123456", item.id)
assertEquals("Show.S01.1080p.WEB-DL", item.name)
assertEquals("completed", item.status)
assertEquals(1.0f, item.progressFraction)
assertEquals(
listOf("Show.S01E01.1080p.WEB-DL.mkv"),
item.playableFiles.map { it.name },
)
}
@Test
fun `truncated mylist payload surfaces an error instead of an empty library`() {
val truncated = torboxRealMylistPayload.take(torboxRealMylistPayload.length / 2) + "\n...[truncated]"
val response = mylistResponse(rawBody = truncated)
assertNull(response.body)
assertFailsWith<IllegalStateException> {
response.toCloudLibraryItemsOrThrow(
providerId = "torbox",
providerName = "Torbox",
type = CloudLibraryItemType.Torrent,
)
}
}
@Test
fun `error envelope surfaces detail message`() {
val payload = """{"success":false,"error":"BAD_TOKEN","detail":"Your token is invalid or has expired.","data":null}"""
val error = assertFailsWith<IllegalStateException> {
mylistResponse(status = 403, rawBody = payload).toCloudLibraryItemsOrThrow(
providerId = "torbox",
providerName = "Torbox",
type = CloudLibraryItemType.Torrent,
)
}
assertEquals("Your token is invalid or has expired.", error.message)
}
private fun mylistResponse(
status: Int = 200,
rawBody: String,
): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
DebridApiResponse(
status = status,
body = runCatching {
DebridApiJson.json.decodeFromString<TorboxEnvelopeDto<List<TorboxCloudItemDto>>>(rawBody)
}.getOrNull(),
rawBody = rawBody,
)
}

View file

@ -167,6 +167,7 @@ actual suspend fun httpRequestRaw(
headers: Map<String, String>,
body: String,
followRedirects: Boolean,
maxResponseBodyBytes: Int,
): RawHttpResponse =
addonHttpClient
.request {