feat: Enhance catalog fetching and parsing; add raw item count and max items support; update HomeCatalogParser and related models

This commit is contained in:
tapframe 2026-03-29 12:54:17 +05:30
parent d3fbfb1716
commit e569301fe0
7 changed files with 131 additions and 30 deletions

View file

@ -10,6 +10,7 @@ const val CATALOG_PAGE_SIZE = 100
data class CatalogPage(
val items: List<MetaPreview>,
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,
)
}

View file

@ -12,24 +12,37 @@ internal object HomeCatalogParser {
ignoreUnknownKeys = true
}
fun parseCatalog(payload: String): List<MetaPreview> {
return parseCatalogResponse(payload).items
fun parseCatalog(
payload: String,
maxItems: Int? = null,
): List<MetaPreview> {
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<String>()
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,
)
}

View file

@ -34,11 +34,12 @@ data class HomeCatalogSection(
val manifestUrl: String,
val catalogId: String,
val items: List<MetaPreview>,
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,

View file

@ -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<Result<HomeCatalogSection>>()
requests.chunked(HOME_CATALOG_FETCH_BATCH_SIZE).forEach { batch ->
val prioritizedRequests = prioritizeDefinitions(
definitions = requests,
snapshot = HomeCatalogSettingsRepository.snapshot(),
)
val loadedSections = linkedMapOf<String, HomeCatalogSection>()
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<HomeCatalogDefinition>,
snapshot: HomeCatalogSettingsSnapshot,
): List<HomeCatalogDefinition> {
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
}

View file

@ -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)
}
}
}

View file

@ -294,6 +294,7 @@ object SearchRepository {
manifestUrl = manifest.transportUrl,
catalogId = catalogId,
items = items,
availableItemCount = page.rawItemCount,
supportsPagination = supportsPagination,
)
}

View file

@ -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() },
)
}
}