From 7fa0c5fab7b0efb4b99d512c77fe1e90492d23dc Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Fri, 12 Jun 2026 02:41:39 -0700 Subject: [PATCH 1/3] fix: stop truncating raw HTTP bodies on Android so Torbox cloud library loads Android httpRequestRaw capped response bodies at 1 MiB and appended a truncation marker, corrupting large Torbox mylist JSON. The decode error was swallowed, so the cloud library rendered as silently empty. Read the full body like iOS/desktop already do, and surface undecodable Torbox list responses as a provider error instead of an empty library. Fixes #1216 Co-Authored-By: Claude Fable 5 --- .../features/addons/AddonPlatform.android.kt | 51 +------ .../cloud/TorboxCloudLibraryProviderApi.kt | 35 +++-- .../TorboxCloudLibraryProviderApiTest.kt | 143 ++++++++++++++++++ 3 files changed, 168 insertions(+), 61 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index 01afe4ef3..03b8bd842 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -16,8 +16,6 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.net.Proxy -import java.io.ByteArrayOutputStream -import java.io.InputStream import kotlin.text.Charsets import java.util.concurrent.TimeUnit @@ -88,8 +86,6 @@ 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 fun requestAllowsBody(method: String): Boolean = when (method.uppercase()) { @@ -105,51 +101,6 @@ private fun Map.withoutAcceptEncoding(): Map = private fun Map.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) - var remaining = maxBytes - var truncated = false - - while (remaining > 0) { - val read = stream.read(buffer, 0, minOf(buffer.size, remaining)) - if (read <= 0) break - out.write(buffer, 0, read) - remaining -= read - } - - if (remaining == 0) { - truncated = stream.read() != -1 - } - - return LimitedReadResult(out.toByteArray(), truncated) -} - -private fun readResponseBodyLimited(body: ResponseBody?): 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) - } - - val decoded = try { - String(readResult.bytes, charset) - } catch (_: Exception) { - String(readResult.bytes, Charsets.UTF_8) - } - - return if (readResult.truncated) { - decoded + truncationSuffix - } else { - decoded - } -} - private fun readResponseBody(body: ResponseBody?): String { if (body == null) return "" val bytes = body.bytes() @@ -277,7 +228,7 @@ actual suspend fun httpRequestRaw( status = response.code, statusText = response.message, url = response.request.url.toString(), - body = readResponseBodyLimited(response.body), + body = readResponseBody(response.body), headers = response.headers.toMultimap().mapValues { (_, values) -> values.joinToString(",") }.mapKeys { (name, _) -> 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 ab098ffd0..643f87d66 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 @@ -62,17 +62,30 @@ internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi { private fun com.nuvio.app.features.debrid.DebridApiResponse>>.itemsOrThrow( type: CloudLibraryItemType, - ): List { - 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 = + toCloudLibraryItemsOrThrow( + providerId = provider.id, + providerName = provider.displayName, + type = type, + ) +} + +internal fun com.nuvio.app.features.debrid.DebridApiResponse>>.toCloudLibraryItemsOrThrow( + providerId: String, + providerName: String, + type: CloudLibraryItemType, +): List { + if (!isSuccessful || body?.success == false) { + throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() }) + } + 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, + ) } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt index 92e15b525..c5326ff77 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt @@ -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,143 @@ 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 = realMylistPayload).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 = realMylistPayload.take(realMylistPayload.length / 2) + "\n...[truncated]" + val response = mylistResponse(rawBody = truncated) + + assertNull(response.body) + assertFailsWith { + 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 { + 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>> = + DebridApiResponse( + status = status, + body = runCatching { + DebridApiJson.json.decodeFromString>>(rawBody) + }.getOrNull(), + rawBody = rawBody, + ) + + private val realMylistPayload = """ + { + "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() } From 250d9d79cf2e8c1f84d862f265ab064ad4830906 Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Fri, 12 Jun 2026 03:03:47 -0700 Subject: [PATCH 2/3] fix(cloud): guarantee an error message for failed Torbox responses The failure path now falls back to a message carrying the HTTP status when the body has no detail/error and the raw body is blank, and the large mylist fixture moves to TorboxCloudLibraryFixtures.kt so the test file stays scannable. Co-Authored-By: Claude Fable 5 --- .../cloud/TorboxCloudLibraryProviderApi.kt | 7 +- .../cloud/TorboxCloudLibraryFixtures.kt | 80 ++++++++++++++++++ .../TorboxCloudLibraryProviderApiTest.kt | 83 +------------------ 3 files changed, 88 insertions(+), 82 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt 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 643f87d66..463832f2e 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 @@ -76,7 +76,12 @@ internal fun com.nuvio.app.features.debrid.DebridApiResponse { if (!isSuccessful || body?.success == false) { - throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() }) + 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).") diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt new file mode 100644 index 000000000..770e2a02f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt @@ -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() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt index c5326ff77..b89bccc73 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt @@ -197,7 +197,7 @@ class TorboxCloudLibraryProviderApiTest { @Test fun `decodes real Torbox mylist payload and maps playable items`() { - val items = mylistResponse(rawBody = realMylistPayload).toCloudLibraryItemsOrThrow( + val items = mylistResponse(rawBody = torboxRealMylistPayload).toCloudLibraryItemsOrThrow( providerId = "torbox", providerName = "Torbox", type = CloudLibraryItemType.Torrent, @@ -217,7 +217,7 @@ class TorboxCloudLibraryProviderApiTest { @Test fun `truncated mylist payload surfaces an error instead of an empty library`() { - val truncated = realMylistPayload.take(realMylistPayload.length / 2) + "\n...[truncated]" + val truncated = torboxRealMylistPayload.take(torboxRealMylistPayload.length / 2) + "\n...[truncated]" val response = mylistResponse(rawBody = truncated) assertNull(response.body) @@ -254,83 +254,4 @@ class TorboxCloudLibraryProviderApiTest { }.getOrNull(), rawBody = rawBody, ) - - private val realMylistPayload = """ - { - "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() } From 50bb2bcc5248bb9ce8569ca2480ebd390393c245 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:40:53 +0530 Subject: [PATCH 3/3] fix: bound generic Android raw responses --- .../features/addons/AddonPlatform.android.kt | 46 ++++++++++++++++++- .../app/features/addons/AddonPlatform.kt | 4 ++ .../app/features/debrid/DebridApiClients.kt | 8 ++++ .../app/features/addons/AddonPlatform.ios.kt | 1 + 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index 03b8bd842..5e41de7d4 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -16,6 +16,8 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.net.Proxy +import java.io.ByteArrayOutputStream +import java.io.InputStream import kotlin.text.Charsets import java.util.concurrent.TimeUnit @@ -87,6 +89,11 @@ private val addonHttpClient = OkHttpClient.Builder() private val jsonMediaType = "application/json; charset=utf-8".toMediaType() +private data class LimitedReadResult( + val bytes: ByteArray, + val truncated: Boolean, +) + private fun requestAllowsBody(method: String): Boolean = when (method.uppercase()) { "POST", "PUT", "PATCH", "DELETE" -> true @@ -101,6 +108,42 @@ private fun Map.withoutAcceptEncoding(): Map = private fun Map.getHeaderIgnoreCase(name: String): String? = entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value +private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResult { + val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024)) + val buffer = ByteArray(8 * 1024) + var remaining = maxBytes + var truncated = false + + while (remaining > 0) { + val read = stream.read(buffer, 0, minOf(buffer.size, remaining)) + if (read <= 0) break + out.write(buffer, 0, read) + remaining -= read + } + + if (remaining == 0) { + truncated = stream.read() != -1 + } + + return LimitedReadResult(out.toByteArray(), truncated) +} + +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, maxBytes.coerceAtLeast(0)) + } + + val decoded = try { + String(readResult.bytes, charset) + } catch (_: Exception) { + String(readResult.bytes, Charsets.UTF_8) + } + + return if (readResult.truncated) "$decoded\n...[truncated]" else decoded +} + private fun readResponseBody(body: ResponseBody?): String { if (body == null) return "" val bytes = body.bytes() @@ -196,6 +239,7 @@ actual suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean, + maxResponseBodyBytes: Int, ): RawHttpResponse = withContext(Dispatchers.IO) { val normalizedMethod = method.uppercase() @@ -228,7 +272,7 @@ actual suspend fun httpRequestRaw( status = response.code, statusText = response.message, url = response.request.url.toString(), - body = readResponseBody(response.body), + body = readResponseBodyLimited(response.body, maxResponseBodyBytes), headers = response.headers.toMultimap().mapValues { (_, values) -> values.joinToString(",") }.mapKeys { (name, _) -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt index e416e64ca..75e68beb4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt @@ -15,6 +15,9 @@ data class RawHttpResponse( val headers: Map, ) +/** 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, body: String, followRedirects: Boolean = true, + maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes, ): RawHttpResponse 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 f27870be5..4e505706a 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 @@ -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>> = @@ -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>> = @@ -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 { 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, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt index 8b6cfc112..ec291bd5e 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt @@ -167,6 +167,7 @@ actual suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean, + maxResponseBodyBytes: Int, ): RawHttpResponse = addonHttpClient .request {