From e569301fe061eb7c64bf36e2cb378df41fd83447 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:54:17 +0530 Subject: [PATCH] feat: Enhance catalog fetching and parsing; add raw item count and max items support; update HomeCatalogParser and related models --- .../nuvio/app/features/catalog/CatalogData.kt | 8 +- .../app/features/home/HomeCatalogParser.kt | 37 ++++++--- .../com/nuvio/app/features/home/HomeModels.kt | 3 +- .../nuvio/app/features/home/HomeRepository.kt | 77 ++++++++++++++++--- .../com/nuvio/app/features/home/HomeScreen.kt | 12 ++- .../app/features/search/SearchRepository.kt | 1 + .../features/home/HomeCatalogParserTest.kt | 23 ++++++ 7 files changed, 131 insertions(+), 30 deletions(-) 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 ace9fc985..a10f720a3 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 @@ -10,6 +10,7 @@ const val CATALOG_PAGE_SIZE = 100 data class CatalogPage( val items: List, + val rawItemCount: Int, val nextSkip: Int?, ) @@ -20,6 +21,7 @@ suspend fun fetchCatalogPage( genre: String? = null, search: String? = null, skip: Int? = null, + maxItems: Int? = null, ): CatalogPage { val payload = httpGetText( buildCatalogUrl( @@ -31,7 +33,10 @@ suspend fun fetchCatalogPage( skip = skip, ), ) - val parsed = HomeCatalogParser.parseCatalogResponse(payload) + val parsed = HomeCatalogParser.parseCatalogResponse( + payload = payload, + maxItems = maxItems, + ) val nextSkip = if (parsed.rawItemCount >= CATALOG_PAGE_SIZE) { (skip ?: 0) + CATALOG_PAGE_SIZE } else { @@ -39,6 +44,7 @@ suspend fun fetchCatalogPage( } return CatalogPage( items = parsed.items, + rawItemCount = parsed.rawItemCount, nextSkip = nextSkip, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt index 9949e88e9..7efbf059d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt @@ -12,24 +12,37 @@ internal object HomeCatalogParser { ignoreUnknownKeys = true } - fun parseCatalog(payload: String): List { - return parseCatalogResponse(payload).items + fun parseCatalog( + payload: String, + maxItems: Int? = null, + ): List { + return parseCatalogResponse( + payload = payload, + maxItems = maxItems, + ).items } - fun parseCatalogResponse(payload: String): ParsedCatalogResponse { + fun parseCatalogResponse( + payload: String, + maxItems: Int? = null, + ): ParsedCatalogResponse { val root = json.parseToJsonElement(payload).jsonObject - val parsedItems = root.array("metas") - .mapNotNull { element -> - val meta = element as? JsonObject ?: return@mapNotNull null + val metas = root.array("metas") + val parsedItems = buildList { + val seenKeys = mutableSetOf() + metas.forEach { element -> + if (maxItems != null && size >= maxItems) return@forEach + + val meta = element as? JsonObject ?: return@forEach val id = meta.string("id") val type = meta.string("type") val name = meta.string("name") if (id.isNullOrBlank() || type.isNullOrBlank() || name.isNullOrBlank()) { - return@mapNotNull null + return@forEach } - MetaPreview( + val item = MetaPreview( id = id, type = type, name = name, @@ -44,10 +57,14 @@ internal object HomeCatalogParser { genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() } }, ) + if (seenKeys.add(item.stableKey())) { + add(item) + } } + } return ParsedCatalogResponse( - items = parsedItems.distinctBy { it.stableKey() }, - rawItemCount = root.array("metas").size, + items = parsedItems, + rawItemCount = metas.size, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt index 8013d2e0f..5cb6ae161 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt @@ -34,11 +34,12 @@ data class HomeCatalogSection( val manifestUrl: String, val catalogId: String, val items: List, + val availableItemCount: Int = items.size, val supportsPagination: Boolean = false, ) fun HomeCatalogSection.canOpenCatalog(previewLimit: Int): Boolean = - items.size > previewLimit || (supportsPagination && items.size >= CATALOG_PAGE_SIZE) + availableItemCount > previewLimit || (supportsPagination && availableItemCount >= CATALOG_PAGE_SIZE) data class HomeUiState( val isLoading: Boolean = false, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index d2b755479..1dd1980cb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.math.absoluteValue import kotlin.random.Random object HomeRepository { @@ -64,27 +65,59 @@ object HomeRepository { activeJob?.cancel() _uiState.update { it.copy(isLoading = true, errorMessage = null) } activeJob = scope.launch { - val results = mutableListOf>() - requests.chunked(HOME_CATALOG_FETCH_BATCH_SIZE).forEach { batch -> + val prioritizedRequests = prioritizeDefinitions( + definitions = requests, + snapshot = HomeCatalogSettingsRepository.snapshot(), + ) + val loadedSections = linkedMapOf() + var firstErrorMessage: String? = null + + prioritizedRequests.chunked(HOME_CATALOG_FETCH_BATCH_SIZE).forEach { batch -> if (activeRequestKey != requestKey) return@launch - results += batch.map { request -> + val results = batch.map { request -> async { runCatching { request.toSection() } } }.awaitAll() + + if (activeRequestKey != requestKey) return@launch + + results.mapNotNull { it.getOrNull() }.forEach { section -> + loadedSections[section.key] = section + } + if (firstErrorMessage == null) { + firstErrorMessage = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message } + } + cachedSections = loadedSections.toMap() + lastErrorMessage = firstErrorMessage + publishCurrentState( + isLoading = true, + requestKey = requestKey, + ) } if (activeRequestKey != requestKey) return@launch - cachedSections = results - .mapNotNull { it.getOrNull() } - .associateBy { it.key } - lastErrorMessage = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message } - applyCurrentSettings() + cachedSections = loadedSections.toMap() + lastErrorMessage = firstErrorMessage + publishCurrentState( + isLoading = false, + requestKey = requestKey, + ) } } fun applyCurrentSettings() { + publishCurrentState( + isLoading = _uiState.value.isLoading, + requestKey = activeRequestKey ?: lastRequestKey, + ) + } + + private fun publishCurrentState( + isLoading: Boolean, + requestKey: String?, + ) { val snapshot = HomeCatalogSettingsRepository.snapshot() val preferences = snapshot.preferences val sections = currentDefinitions @@ -101,19 +134,20 @@ object HomeRepository { } val heroItems = if (snapshot.heroEnabled) { + val heroRandom = Random((requestKey?.hashCode() ?: 0).absoluteValue + 1) currentDefinitions .filter { definition -> preferences[definition.key]?.heroSourceEnabled != false } .mapNotNull { definition -> cachedSections[definition.key] } .flatMap { section -> section.items } .distinctBy { item -> "${item.type}:${item.id}" } - .shuffled(Random.Default) + .shuffled(heroRandom) .take(HOME_HERO_ITEM_LIMIT) } else { emptyList() } _uiState.value = HomeUiState( - isLoading = false, + isLoading = isLoading, heroItems = heroItems, sections = sections, errorMessage = if (sections.isEmpty()) lastErrorMessage else null, @@ -125,6 +159,7 @@ object HomeRepository { manifestUrl = manifestUrl, type = type, catalogId = catalogId, + maxItems = HOME_CATALOG_PREVIEW_FETCH_LIMIT, ) val items = page.items require(items.isNotEmpty()) { "No feed items returned for $defaultTitle." } @@ -138,10 +173,30 @@ object HomeRepository { manifestUrl = manifestUrl, catalogId = catalogId, items = items, + availableItemCount = page.rawItemCount, supportsPagination = supportsPagination, ) } } private const val HOME_HERO_ITEM_LIMIT = 8 -private const val HOME_CATALOG_FETCH_BATCH_SIZE = 4 +private const val HOME_CATALOG_FETCH_BATCH_SIZE = 2 +private const val HOME_CATALOG_PREVIEW_FETCH_LIMIT = 18 + +private fun prioritizeDefinitions( + definitions: List, + snapshot: HomeCatalogSettingsSnapshot, +): List { + val orderedDefinitions = definitions.sortedBy { definition -> + snapshot.preferences[definition.key]?.order ?: Int.MAX_VALUE + } + val (priority, remainder) = orderedDefinitions.partition { definition -> + val preference = snapshot.preferences[definition.key] + if (preference == null) { + true + } else { + preference.enabled || (snapshot.heroEnabled && preference.heroSourceEnabled) + } + } + return priority + remainder +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index b2f3498e2..787f28a9c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -130,12 +130,10 @@ fun HomeScreen( val hasActiveAddons = addonsUiState.addons.any { it.manifest != null } val showHeroSlot = homeSettingsUiState.heroEnabled - val homeDataResolved = !homeUiState.isLoading && - (homeUiState.sections.isNotEmpty() || homeUiState.errorMessage != null) + val isResolvingHeroSources = addonsUiState.addons.any { it.isRefreshing } || homeUiState.isLoading val showHeroSkeleton = showHeroSlot && - hasActiveAddons && homeUiState.heroItems.isEmpty() && - !homeDataResolved + isResolvingHeroSources NuvioScreen( modifier = modifier, @@ -146,16 +144,16 @@ fun HomeScreen( item { when { showHeroSkeleton -> HomeSkeletonHero( - modifier = Modifier.padding(bottom = 12.dp), + modifier = Modifier, ) homeUiState.heroItems.isNotEmpty() -> HomeHeroSection( items = homeUiState.heroItems, - modifier = Modifier.padding(bottom = 0.dp), + modifier = Modifier, onItemClick = onPosterClick, ) - else -> HomeHeroReservedSpace() + else -> HomeHeroReservedSpace(modifier = Modifier) } } } 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 d97aa9e32..04ec4c635 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 @@ -294,6 +294,7 @@ object SearchRepository { manifestUrl = manifest.transportUrl, catalogId = catalogId, items = items, + availableItemCount = page.rawItemCount, supportsPagination = supportsPagination, ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt index 5c4deda16..d44a3b822 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt @@ -26,4 +26,27 @@ class HomeCatalogParserTest { ) assertEquals("A", result.items.first().name) } + + @Test + fun `parse catalog response respects max item cap without losing raw count`() { + val result = HomeCatalogParser.parseCatalogResponse( + payload = """ + { + "metas": [ + { "id": "tt1", "type": "movie", "name": "One" }, + { "id": "tt1", "type": "movie", "name": "One duplicate" }, + { "id": "tt2", "type": "movie", "name": "Two" }, + { "id": "tt3", "type": "movie", "name": "Three" } + ] + } + """.trimIndent(), + maxItems = 2, + ) + + assertEquals(4, result.rawItemCount) + assertEquals( + listOf("movie:tt1", "movie:tt2"), + result.items.map { it.stableKey() }, + ) + } }