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 3bdd5dc6e..1d147c153 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 @@ -54,7 +54,7 @@ private val addonHttpClient = OkHttpClient.Builder() .build() private val jsonMediaType = "application/json; charset=utf-8".toMediaType() -private const val maxResponseBodyBytes = 256 * 1024 +private const val maxRawResponseBodyBytes = 1024 * 1024 private const val truncationSuffix = "\n...[truncated]" private fun requestAllowsBody(method: String): Boolean = @@ -100,7 +100,7 @@ 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, maxResponseBodyBytes) + readAtMostBytes(stream, maxRawResponseBodyBytes) } val decoded = try { @@ -116,6 +116,17 @@ private fun readResponseBodyLimited(body: ResponseBody?): String { } } +private fun readResponseBody(body: ResponseBody?): String { + if (body == null) return "" + val bytes = body.bytes() + return runCatching { + val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + String(bytes, charset) + }.getOrElse { + String(bytes, Charsets.UTF_8) + } +} + private suspend fun executeTextRequest( method: String, url: String, @@ -140,7 +151,7 @@ private suspend fun executeTextRequest( }.build() addonHttpClient.newCall(request).execute().use { response -> - val payload = readResponseBodyLimited(response.body) + val payload = readResponseBody(response.body) if (!response.isSuccessful) { error("Request failed with HTTP ${response.code}") } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt index 974a3b8ba..a838864ce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt @@ -97,7 +97,7 @@ fun mergeCatalogItems( } } -private fun buildCatalogUrl( +internal fun buildCatalogUrl( manifestUrl: String, type: String, catalogId: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt index b4d72368d..12fe05f49 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -1,12 +1,15 @@ package com.nuvio.app.features.search +import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.catalog.buildCatalogUrl import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.supportsPagination import com.nuvio.app.features.home.HomeCatalogSection +import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -19,6 +22,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch object SearchRepository { + private val log = Logger.withTag("SearchRepository") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _uiState = MutableStateFlow(SearchUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -120,6 +124,7 @@ object SearchRepository { if (activeAddons.isEmpty()) { activeDiscoverJob?.cancel() discoverSources = emptyList() + log.d { "Discover refresh aborted: no active addons" } _discoverUiState.value = DiscoverUiState( emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons, ) @@ -129,12 +134,17 @@ object SearchRepository { val sources = buildDiscoverSources(activeAddons) val current = _discoverUiState.value if (sources == discoverSources && current.canReuseDiscoverState(sources)) { + log.d { + "Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " + + "genre=${current.selectedGenre ?: ""} items=${current.items.size} nextSkip=${current.nextSkip}" + } return } discoverSources = sources if (sources.isEmpty()) { activeDiscoverJob?.cancel() + log.d { "Discover refresh found no compatible discover catalogs" } _discoverUiState.value = DiscoverUiState( emptyStateReason = DiscoverEmptyStateReason.NoDiscoverCatalogs, ) @@ -162,6 +172,11 @@ object SearchRepository { errorMessage = null, ) + log.d { + "Discover refresh prepared type=$selectedType catalog=${selectedCatalog.key} " + + "genre=${selectedGenre ?: ""} sources=${sources.size}" + } + loadDiscoverFeed(reset = true) } @@ -314,6 +329,20 @@ object SearchRepository { val current = _discoverUiState.value val selectedCatalog = current.selectedCatalog ?: return val requestedSkip = if (reset) 0 else current.nextSkip ?: return + val requestUrl = buildCatalogUrl( + manifestUrl = selectedCatalog.manifestUrl, + type = selectedCatalog.type, + catalogId = selectedCatalog.catalogId, + genre = current.selectedGenre, + search = null, + skip = requestedSkip.takeIf { it > 0 }, + ) + + log.d { + "Discover request reset=$reset addon=${selectedCatalog.addonName} type=${selectedCatalog.type} " + + "catalogId=${selectedCatalog.catalogId} catalogKey=${selectedCatalog.key} " + + "genre=${current.selectedGenre ?: ""} skip=$requestedSkip url=$requestUrl" + } _discoverUiState.value = current.copy( isLoading = true, @@ -343,6 +372,11 @@ object SearchRepository { } else { mergeCatalogItems(latest.items, page.items) } + log.d { + "Discover response catalogKey=${selectedCatalog.key} returned=${page.items.size} " + + "merged=${mergedItems.size} rawItemCount=${page.rawItemCount} nextSkip=${page.nextSkip} " + + "sample=${page.items.previewNames()}" + } _discoverUiState.value = latest.copy( items = mergedItems, isLoading = false, @@ -356,6 +390,11 @@ object SearchRepository { if (latest.selectedCatalogKey != selectedCatalog.key || latest.selectedGenre != current.selectedGenre) { return@fold } + log.e(error) { + "Discover request failed catalogKey=${selectedCatalog.key} addon=${selectedCatalog.addonName} " + + "type=${selectedCatalog.type} catalogId=${selectedCatalog.catalogId} " + + "genre=${current.selectedGenre ?: ""} skip=$requestedSkip url=$requestUrl" + } _discoverUiState.value = latest.copy( items = if (reset) emptyList() else latest.items, isLoading = false, @@ -429,6 +468,13 @@ private fun DiscoverUiState.canReuseDiscoverState( return isLoading || items.isNotEmpty() || emptyStateReason != null || errorMessage != null || nextSkip != null } +private fun List.previewNames(limit: Int = 5): String { + if (isEmpty()) return "[]" + return take(limit).joinToString(prefix = "[", postfix = if (size > limit) ", ...]" else "]") { item -> + item.name + } +} + private fun String.displayLabel(): String = replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase() else char.toString()