mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-09 00:17:59 +00:00
Merge PR #1331: fix Torbox cloud library on Android
This commit is contained in:
commit
78b826fdf7
7 changed files with 196 additions and 26 deletions
|
|
@ -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, _) ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ actual suspend fun httpRequestRaw(
|
|||
headers: Map<String, String>,
|
||||
body: String,
|
||||
followRedirects: Boolean,
|
||||
maxResponseBodyBytes: Int,
|
||||
): RawHttpResponse =
|
||||
addonHttpClient
|
||||
.request {
|
||||
|
|
|
|||
Loading…
Reference in a new issue