Merge branch 'pagination' into dev
This commit is contained in:
commit
367f5b1e78
14 changed files with 111 additions and 41 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue