mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 15:02:25 +00:00
feat: Enhance catalog fetching and parsing; add raw item count and max items support; update HomeCatalogParser and related models
This commit is contained in:
parent
d3fbfb1716
commit
e569301fe0
7 changed files with 131 additions and 30 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ object SearchRepository {
|
|||
manifestUrl = manifest.transportUrl,
|
||||
catalogId = catalogId,
|
||||
items = items,
|
||||
availableItemCount = page.rawItemCount,
|
||||
supportsPagination = supportsPagination,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue