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 8d60a4300..cbe2f376c 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 @@ -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.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) @@ -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, 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, _) -> 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/cloud/TorboxCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt index ab098ffd0..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 @@ -62,17 +62,35 @@ 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() } + ?: "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, + ) } } 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/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 92e15b525..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 @@ -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 { + 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, + ) } 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 {