Merge branch 'pagination' into dev

This commit is contained in:
tapframe 2026-03-03 23:29:24 +05:30
commit 367f5b1e78
14 changed files with 111 additions and 41 deletions

View file

@ -31,13 +31,7 @@ fun CatalogDescriptorDto.toDomain(): CatalogDescriptor {
rawType = manifestType,
id = id,
name = name,
extra = extra.orEmpty().map { dto ->
CatalogExtra(
name = dto.name,
isRequired = dto.isRequired ?: false,
options = dto.options
)
}
extra = parseCatalogExtras(extra)
)
}
@ -67,3 +61,43 @@ private fun parseResources(resources: List<Any>, defaultTypes: List<String>): Li
}
}
}
private fun parseCatalogExtras(rawExtras: List<Any>?): List<CatalogExtra> {
return rawExtras.orEmpty().mapNotNull { raw ->
when (raw) {
is String -> {
val name = raw.trim().lowercase()
if (name.isBlank()) {
null
} else {
CatalogExtra(name = name)
}
}
is Map<*, *> -> {
val name = (raw["name"] as? String)?.trim()?.lowercase().orEmpty()
if (name.isBlank()) return@mapNotNull null
val isRequired = when (val required = raw["isRequired"]) {
is Boolean -> required
is String -> required.equals("true", ignoreCase = true)
is Number -> required.toInt() != 0
else -> false
}
val options = (raw["options"] as? List<*>)?.mapNotNull { option ->
when (option) {
null -> null
is String -> option
else -> option.toString()
}
}?.takeIf { it.isNotEmpty() }
CatalogExtra(
name = name,
isRequired = isRequired,
options = options
)
}
else -> null
}
}.distinct()
}

View file

@ -21,12 +21,5 @@ data class CatalogDescriptorDto(
@Json(name = "type") val type: String,
@Json(name = "id") val id: String,
@Json(name = "name") val name: String,
@Json(name = "extra") val extra: List<ExtraDto>? = null
)
@JsonClass(generateAdapter = true)
data class ExtraDto(
@Json(name = "name") val name: String,
@Json(name = "isRequired") val isRequired: Boolean? = false,
@Json(name = "options") val options: List<String>? = null
@Json(name = "extra") val extra: List<Any>? = null
)

View file

@ -33,6 +33,7 @@ class CatalogRepositoryImpl @Inject constructor(
catalogName: String,
type: String,
skip: Int,
skipStep: Int,
extraArgs: Map<String, String>,
supportsSkip: Boolean
): Flow<NetworkResult<CatalogRow>> = flow {
@ -42,6 +43,7 @@ class CatalogRepositoryImpl @Inject constructor(
type = type,
catalogId = catalogId,
skip = skip,
skipStep = skipStep,
extraArgs = extraArgs
)
@ -56,7 +58,7 @@ class CatalogRepositoryImpl @Inject constructor(
val url = buildCatalogUrl(addonBaseUrl, type, catalogId, skip, extraArgs)
Log.d(
TAG,
"Fetching catalog addonId=$addonId addonName=$addonName type=$type catalogId=$catalogId skip=$skip supportsSkip=$supportsSkip url=$url"
"Fetching catalog addonId=$addonId addonName=$addonName type=$type catalogId=$catalogId skip=$skip skipStep=$skipStep supportsSkip=$supportsSkip url=$url"
)
when (val result = safeApiCall { api.getCatalog(url) }) {
@ -78,8 +80,9 @@ class CatalogRepositoryImpl @Inject constructor(
items = items,
isLoading = false,
hasMore = supportsSkip && items.isNotEmpty(),
currentPage = skip / 100,
supportsSkip = supportsSkip
currentPage = if (skipStep > 0) skip / skipStep else 0,
supportsSkip = supportsSkip,
skipStep = skipStep
)
catalogCache[cacheKey] = catalogRow
// Only emit fresh data if it differs from cache
@ -143,12 +146,13 @@ class CatalogRepositoryImpl @Inject constructor(
type: String,
catalogId: String,
skip: Int,
skipStep: Int,
extraArgs: Map<String, String>
): String {
val normalizedArgs = extraArgs.entries
.sortedBy { it.key }
.joinToString("&") { "${it.key}=${it.value}" }
val normalizedBaseUrl = addonBaseUrl.trim().trimEnd('/').lowercase()
return "${normalizedBaseUrl}_${addonId}_${type}_${catalogId}_${skip}_${normalizedArgs}"
return "${normalizedBaseUrl}_${addonId}_${type}_${catalogId}_${skip}_${skipStep}_${normalizedArgs}"
}
}

View file

@ -0,0 +1,27 @@
package com.nuvio.tv.domain.model
private const val DEFAULT_SKIP_STEP = 100
fun CatalogDescriptor.supportsExtra(name: String): Boolean {
return extra.any { it.name.equals(name, ignoreCase = true) }
}
fun CatalogDescriptor.skipStep(defaultStep: Int = DEFAULT_SKIP_STEP): Int {
val skipExtra = extra.firstOrNull { it.name.equals("skip", ignoreCase = true) } ?: return defaultStep
val numericOptions = skipExtra.options
.orEmpty()
.mapNotNull { it.trim().toIntOrNull() }
.filter { it >= 0 }
.distinct()
.sorted()
if (numericOptions.isEmpty()) return defaultStep
if (numericOptions.size == 1) return numericOptions.first().takeIf { it > 0 } ?: defaultStep
val step = numericOptions
.zipWithNext()
.mapNotNull { (a, b) -> (b - a).takeIf { it > 0 } }
.minOrNull()
return step ?: defaultStep
}

View file

@ -15,7 +15,8 @@ data class CatalogRow(
val isLoading: Boolean = false,
val hasMore: Boolean = true,
val currentPage: Int = 0,
val supportsSkip: Boolean = false
val supportsSkip: Boolean = false,
val skipStep: Int = 100
) {
val apiType: String
get() = type.toApiString(rawType)

View file

@ -13,6 +13,7 @@ interface CatalogRepository {
catalogName: String,
type: String,
skip: Int = 0,
skipStep: Int = 100,
extraArgs: Map<String, String> = emptyMap(),
supportsSkip: Boolean = false
): Flow<NetworkResult<CatalogRow>>

View file

@ -1028,5 +1028,5 @@ private fun AddonCardContent(
}
private fun CatalogDescriptor.isSearchOnlyCatalog(): Boolean {
return extra.any { extra -> extra.name == "search" && extra.isRequired }
return extra.any { extra -> extra.name.equals("search", ignoreCase = true) && extra.isRequired }
}

View file

@ -515,7 +515,7 @@ class AddonManagerViewModel @Inject constructor(
}
private fun CatalogDescriptor.isSearchOnlyCatalog(): Boolean {
return extra.any { extra -> extra.name == "search" && extra.isRequired }
return extra.any { extra -> extra.name.equals("search", ignoreCase = true) && extra.isRequired }
}
private data class QrCatalogEntry(

View file

@ -172,7 +172,7 @@ class CatalogOrderViewModel @Inject constructor(
}
private fun CatalogDescriptor.isSearchOnlyCatalog(): Boolean {
return extra.any { extra -> extra.name == "search" && extra.isRequired }
return extra.any { extra -> extra.name.equals("search", ignoreCase = true) && extra.isRequired }
}
}

View file

@ -7,6 +7,8 @@ import com.nuvio.tv.domain.model.Addon
import com.nuvio.tv.domain.model.CatalogDescriptor
import com.nuvio.tv.domain.model.CatalogRow
import com.nuvio.tv.domain.model.HomeLayout
import com.nuvio.tv.domain.model.skipStep
import com.nuvio.tv.domain.model.supportsExtra
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
@ -159,10 +161,11 @@ internal fun HomeViewModel.loadCatalogPipeline(
var hasCountedCompletion = false
catalogLoadSemaphore.withPermit {
if (generation != catalogLoadGeneration) return@withPermit
val supportsSkip = catalog.extra.any { it.name == "skip" }
val supportsSkip = catalog.supportsExtra("skip")
val skipStep = catalog.skipStep()
Log.d(
HomeViewModel.TAG,
"Loading home catalog addonId=${addon.id} addonName=${addon.name} type=${catalog.apiType} catalogId=${catalog.id} catalogName=${catalog.name} supportsSkip=$supportsSkip"
"Loading home catalog addonId=${addon.id} addonName=${addon.name} type=${catalog.apiType} catalogId=${catalog.id} catalogName=${catalog.name} supportsSkip=$supportsSkip skipStep=$skipStep"
)
catalogRepository.getCatalog(
addonBaseUrl = addon.baseUrl,
@ -172,6 +175,7 @@ internal fun HomeViewModel.loadCatalogPipeline(
catalogName = catalog.name,
type = catalog.apiType,
skip = 0,
skipStep = skipStep,
supportsSkip = supportsSkip
).collect { result ->
if (generation != catalogLoadGeneration) return@collect
@ -233,7 +237,7 @@ internal fun HomeViewModel.loadMoreCatalogItemsPipeline(catalogId: String, addon
viewModelScope.launch {
val addon = addonsCache.find { it.id == addonId } ?: return@launch
val nextSkip = currentRow.items.size
val nextSkip = (currentRow.currentPage + 1) * currentRow.skipStep
catalogRepository.getCatalog(
addonBaseUrl = addon.baseUrl,
addonId = addon.id,
@ -242,6 +246,7 @@ internal fun HomeViewModel.loadMoreCatalogItemsPipeline(catalogId: String, addon
catalogName = currentRow.catalogName,
type = currentRow.apiType,
skip = nextSkip,
skipStep = currentRow.skipStep,
supportsSkip = currentRow.supportsSkip
).collect { result ->
when (result) {

View file

@ -111,7 +111,7 @@ internal fun HomeViewModel.disableCatalogKey(
}
internal fun CatalogDescriptor.isSearchOnlyCatalog(): Boolean {
return extra.any { extra -> extra.name == "search" && extra.isRequired }
return extra.any { extra -> extra.name.equals("search", ignoreCase = true) && extra.isRequired }
}
internal fun MetaPreview.hasHeroArtwork(): Boolean {

View file

@ -44,5 +44,6 @@ data class DiscoverCatalog(
val catalogName: String,
val type: String,
val genres: List<String>,
val supportsSkip: Boolean
val supportsSkip: Boolean,
val skipStep: Int
)

View file

@ -7,6 +7,8 @@ import com.nuvio.tv.data.local.LayoutPreferenceDataStore
import com.nuvio.tv.domain.model.Addon
import com.nuvio.tv.domain.model.CatalogDescriptor
import com.nuvio.tv.domain.model.CatalogRow
import com.nuvio.tv.domain.model.skipStep
import com.nuvio.tv.domain.model.supportsExtra
import com.nuvio.tv.core.util.filterReleasedItems
import com.nuvio.tv.core.util.isUnreleased
import com.nuvio.tv.domain.repository.AddonRepository
@ -196,6 +198,7 @@ class SearchViewModel @Inject constructor(
catalogName = catalog.name,
type = catalog.apiType,
skip = 0,
skipStep = 100,
extraArgs = mapOf("search" to query),
supportsSkip = false
).collect { result ->
@ -324,7 +327,8 @@ class SearchViewModel @Inject constructor(
}
private suspend fun loadCatalog(addon: Addon, catalog: CatalogDescriptor, query: String) {
val supportsSkip = catalog.extra.any { it.name == "skip" }
val supportsSkip = catalog.supportsExtra("skip")
val skipStep = catalog.skipStep()
catalogRepository.getCatalog(
addonBaseUrl = addon.baseUrl,
addonId = addon.id,
@ -333,6 +337,7 @@ class SearchViewModel @Inject constructor(
catalogName = catalog.name,
type = catalog.apiType,
skip = 0,
skipStep = skipStep,
extraArgs = mapOf("search" to query),
supportsSkip = supportsSkip
).collect { result ->
@ -383,8 +388,7 @@ class SearchViewModel @Inject constructor(
return@launch
}
// Use actual loaded item count for skip, not fixed 100-page size
val nextSkip = currentRow.items.size
val nextSkip = (currentRow.currentPage + 1) * currentRow.skipStep
catalogRepository.getCatalog(
addonBaseUrl = addon.baseUrl,
addonId = addon.id,
@ -393,6 +397,7 @@ class SearchViewModel @Inject constructor(
catalogName = currentRow.catalogName,
type = currentRow.apiType,
skip = nextSkip,
skipStep = currentRow.skipStep,
extraArgs = mapOf("search" to query),
supportsSkip = currentRow.supportsSkip
).collect { result ->
@ -465,11 +470,12 @@ class SearchViewModel @Inject constructor(
val discoverCatalogs = addons.flatMap { addon ->
addon.catalogs
.filter { catalog ->
!catalog.extra.any { it.name == "search" && it.isRequired }
!(catalog.supportsExtra("search") &&
catalog.extra.any { it.name.equals("search", ignoreCase = true) && it.isRequired })
}
.map { catalog ->
val genres = catalog.extra
.firstOrNull { it.name == "genre" }
.firstOrNull { it.name.equals("genre", ignoreCase = true) }
?.options
.orEmpty()
DiscoverCatalog(
@ -481,7 +487,8 @@ class SearchViewModel @Inject constructor(
catalogName = catalog.name,
type = catalog.apiType,
genres = genres,
supportsSkip = catalog.extra.any { it.name == "skip" }
supportsSkip = catalog.supportsExtra("skip"),
skipStep = catalog.skipStep()
)
}
}
@ -618,7 +625,7 @@ class SearchViewModel @Inject constructor(
}
val currentPage = if (reset) 1 else state.discoverPage + 1
val skip = if (currentPage <= 1) 0 else state.discoverResults.size + state.pendingDiscoverResults.size
val skip = if (currentPage <= 1) 0 else (currentPage - 1) * selectedCatalog.skipStep
val visibleCountBeforeRequest = state.discoverResults.size
val extraArgs = buildMap<String, String> {
state.selectedDiscoverGenre?.takeIf { it.isNotBlank() }?.let { put("genre", it) }
@ -632,6 +639,7 @@ class SearchViewModel @Inject constructor(
catalogName = selectedCatalog.catalogName,
type = selectedCatalog.type,
skip = skip,
skipStep = selectedCatalog.skipStep,
extraArgs = extraArgs,
supportsSkip = selectedCatalog.supportsSkip
).collect { result ->
@ -710,15 +718,11 @@ class SearchViewModel @Inject constructor(
val allSearchTargets = addons.flatMap { addon ->
addon.catalogs
.filter { catalog ->
catalog.extra.any { it.name == "search" }
catalog.supportsExtra("search")
}
.map { catalog -> addon to catalog }
}
val requiredSearchTargets = allSearchTargets.filter { (_, catalog) ->
catalog.extra.any { it.name == "search" && it.isRequired }
}
return allSearchTargets
}

View file

@ -433,7 +433,7 @@ class LayoutSettingsViewModel @Inject constructor(
val catalogs = addons.flatMap { addon ->
addon.catalogs
.filter { catalog ->
!catalog.extra.any { it.name == "search" && it.isRequired }
!catalog.extra.any { it.name.equals("search", ignoreCase = true) && it.isRequired }
}
.map { catalog ->
CatalogInfo(