perf: coroutine cancellation.optimize addon manifest loading with shared flows
This commit is contained in:
parent
b31514e65e
commit
6e049fb02d
12 changed files with 345 additions and 75 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -43,3 +43,4 @@ tv-samples
|
|||
HOME_UI_OPTIMIZATION_NOTES.local.md
|
||||
.kotlin/
|
||||
bugs/
|
||||
logs/
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
android:name=".NuvioApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/banner"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
|
|
|
|||
|
|
@ -102,15 +102,14 @@ class StartupSyncService @Inject constructor(
|
|||
startupPullJob = scope.launch {
|
||||
val maxAttempts = 3
|
||||
var syncCompleted = false
|
||||
repeat(maxAttempts) { index ->
|
||||
val attempt = index + 1
|
||||
for (attempt in 1..maxAttempts) {
|
||||
Log.d(TAG, "Startup sync attempt $attempt/$maxAttempts for key=$key")
|
||||
val result = pullRemoteData()
|
||||
if (result.isSuccess) {
|
||||
lastPulledKey = key
|
||||
Log.d(TAG, "Startup sync completed for key=$key")
|
||||
syncCompleted = true
|
||||
return@repeat
|
||||
break
|
||||
}
|
||||
|
||||
Log.w(TAG, "Startup sync attempt $attempt failed for key=$key", result.exceptionOrNull())
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.nuvio.tv.domain.model.MetaCompany
|
|||
import com.nuvio.tv.domain.model.MetaPreview
|
||||
import com.nuvio.tv.domain.model.PersonDetail
|
||||
import com.nuvio.tv.domain.model.PosterShape
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
|
|
@ -292,6 +293,8 @@ class TmdbMetadataService @Inject constructor(
|
|||
)
|
||||
enrichmentCache[cacheKey] = enrichment
|
||||
enrichment
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch TMDB enrichment: ${e.message}", e)
|
||||
null
|
||||
|
|
@ -318,6 +321,8 @@ class TmdbMetadataService @Inject constructor(
|
|||
val epNum = ep.episodeNumber ?: return@forEach
|
||||
result[season to epNum] = ep.toEnrichment()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.tv.core.tmdb
|
|||
|
||||
import android.util.Log
|
||||
import com.nuvio.tv.data.remote.api.TmdbApi
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
|
@ -89,6 +90,8 @@ class TmdbService @Inject constructor(
|
|||
Log.w(TAG, "No TMDB result found for IMDB: $imdbId")
|
||||
null
|
||||
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error looking up TMDB ID for $imdbId: ${e.message}", e)
|
||||
null
|
||||
|
|
@ -141,6 +144,8 @@ class TmdbService @Inject constructor(
|
|||
Log.w(TAG, "No IMDB ID found for TMDB: $tmdbId")
|
||||
null
|
||||
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error looking up IMDB ID for $tmdbId: ${e.message}", e)
|
||||
null
|
||||
|
|
|
|||
|
|
@ -18,24 +18,31 @@ import kotlinx.coroutines.async
|
|||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import com.nuvio.tv.core.auth.AuthManager
|
||||
import com.nuvio.tv.core.sync.AddonSyncService
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AddonRepositoryImpl @Inject constructor(
|
||||
private val api: AddonApi,
|
||||
private val preferences: AddonPreferences,
|
||||
private val addonSyncService: AddonSyncService,
|
||||
private val authManager: AuthManager,
|
||||
@ApplicationContext private val context: Context
|
||||
@param:ApplicationContext private val context: Context
|
||||
) : AddonRepository {
|
||||
|
||||
companion object {
|
||||
|
|
@ -80,6 +87,33 @@ class AddonRepositoryImpl @Inject constructor(
|
|||
|
||||
private val gson = Gson()
|
||||
private val manifestCache = mutableMapOf<String, Addon>()
|
||||
private val installedAddonsFlow = preferences.installedAddonUrls
|
||||
.flatMapLatest { urls ->
|
||||
flow {
|
||||
// Emit cached addons immediately so consumers can render before refresh completes.
|
||||
val cached = urls.mapNotNull { manifestCache[canonicalizeUrl(it)] }
|
||||
if (cached.isNotEmpty()) {
|
||||
emit(applyDisplayNames(cached))
|
||||
}
|
||||
|
||||
val fresh = coroutineScope {
|
||||
urls.map { url ->
|
||||
async {
|
||||
when (val result = fetchAddon(url)) {
|
||||
is NetworkResult.Success -> result.data
|
||||
else -> manifestCache[canonicalizeUrl(url)]
|
||||
}
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
if (fresh != cached || cached.isEmpty()) {
|
||||
emit(applyDisplayNames(fresh))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.shareIn(syncScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
init {
|
||||
loadManifestCacheFromDisk()
|
||||
|
|
@ -107,32 +141,7 @@ class AddonRepositoryImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun getInstalledAddons(): Flow<List<Addon>> =
|
||||
preferences.installedAddonUrls.flatMapLatest { urls ->
|
||||
flow {
|
||||
// Emit cached addons immediately (now includes disk-persisted cache)
|
||||
val cached = urls.mapNotNull { manifestCache[canonicalizeUrl(it)] }
|
||||
if (cached.isNotEmpty()) {
|
||||
emit(applyDisplayNames(cached))
|
||||
}
|
||||
|
||||
val fresh = coroutineScope {
|
||||
urls.map { url ->
|
||||
async {
|
||||
when (val result = fetchAddon(url)) {
|
||||
is NetworkResult.Success -> result.data
|
||||
else -> manifestCache[canonicalizeUrl(url)]
|
||||
}
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
|
||||
if (fresh != cached || cached.isEmpty()) {
|
||||
emit(applyDisplayNames(fresh))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
override fun getInstalledAddons(): Flow<List<Addon>> = installedAddonsFlow
|
||||
|
||||
override suspend fun fetchAddon(baseUrl: String): NetworkResult<Addon> {
|
||||
val cleanBaseUrl = canonicalizeUrl(baseUrl)
|
||||
|
|
@ -146,7 +155,17 @@ class AddonRepositoryImpl @Inject constructor(
|
|||
NetworkResult.Success(addon)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
Log.w(TAG, "Failed to fetch addon manifest for url=$cleanBaseUrl code=${result.code} message=${result.message}")
|
||||
if (isBenignManifestFetchFailure(result.message)) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Manifest fetch cancelled/short-circuited for url=$cleanBaseUrl message=${result.message}"
|
||||
)
|
||||
} else {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Failed to fetch addon manifest for url=$cleanBaseUrl code=${result.code} message=${result.message}"
|
||||
)
|
||||
}
|
||||
result
|
||||
}
|
||||
NetworkResult.Loading -> NetworkResult.Loading
|
||||
|
|
@ -241,4 +260,11 @@ class AddonRepositoryImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBenignManifestFetchFailure(message: String?): Boolean {
|
||||
val normalized = message?.lowercase().orEmpty()
|
||||
return "flow was aborted" in normalized ||
|
||||
"no more elements needed" in normalized ||
|
||||
"child of the scoped flow was cancelled" in normalized
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ import com.nuvio.tv.domain.repository.AddonRepository
|
|||
import com.nuvio.tv.domain.repository.MetaRepository
|
||||
import com.nuvio.tv.R
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
|
@ -23,7 +28,7 @@ import javax.inject.Singleton
|
|||
|
||||
@Singleton
|
||||
class MetaRepositoryImpl @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: AddonApi,
|
||||
private val addonRepository: AddonRepository
|
||||
) : MetaRepository {
|
||||
|
|
@ -35,6 +40,8 @@ class MetaRepositoryImpl @Inject constructor(
|
|||
private val metaCache = ConcurrentHashMap<String, Meta>()
|
||||
// Separate cache for full meta fetched from addons (bypasses catalog-level cache)
|
||||
private val addonMetaCache = ConcurrentHashMap<String, Meta>()
|
||||
private val inFlightAddonMetaRequests = ConcurrentHashMap<String, Deferred<NetworkResult<Meta>>>()
|
||||
private val requestScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun getMeta(
|
||||
addonBaseUrl: String,
|
||||
|
|
@ -80,6 +87,37 @@ class MetaRepositoryImpl @Inject constructor(
|
|||
|
||||
emit(NetworkResult.Loading)
|
||||
|
||||
val result = awaitAddonMetaResult(cacheKey = cacheKey, type = type, id = id)
|
||||
emit(result)
|
||||
}
|
||||
|
||||
private suspend fun awaitAddonMetaResult(
|
||||
cacheKey: String,
|
||||
type: String,
|
||||
id: String
|
||||
): NetworkResult<Meta> {
|
||||
addonMetaCache[cacheKey]?.let { return NetworkResult.Success(it) }
|
||||
val request = inFlightAddonMetaRequests[cacheKey] ?: requestScope.async {
|
||||
fetchMetaFromAllAddonsResult(cacheKey = cacheKey, type = type, id = id)
|
||||
}.also { created ->
|
||||
val existing = inFlightAddonMetaRequests.putIfAbsent(cacheKey, created)
|
||||
if (existing != null) {
|
||||
created.cancel()
|
||||
}
|
||||
}
|
||||
val activeRequest = inFlightAddonMetaRequests[cacheKey] ?: request
|
||||
return try {
|
||||
activeRequest.await()
|
||||
} finally {
|
||||
inFlightAddonMetaRequests.remove(cacheKey, activeRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchMetaFromAllAddonsResult(
|
||||
cacheKey: String,
|
||||
type: String,
|
||||
id: String
|
||||
): NetworkResult<Meta> {
|
||||
val addons = addonRepository.getInstalledAddons().first()
|
||||
|
||||
val requestedType = type.trim()
|
||||
|
|
@ -130,16 +168,14 @@ class MetaRepositoryImpl @Inject constructor(
|
|||
val meta = metaDto.toDomain(episodeLabel)
|
||||
addonMetaCache[cacheKey] = meta
|
||||
metaCache[cacheKey] = meta
|
||||
emit(NetworkResult.Success(meta))
|
||||
return@flow
|
||||
return NetworkResult.Success(meta)
|
||||
}
|
||||
}
|
||||
else -> { /* Try next addon */ }
|
||||
}
|
||||
}
|
||||
|
||||
emit(NetworkResult.Error("No addons support meta for type: $requestedType"))
|
||||
return@flow
|
||||
return NetworkResult.Error("No addons support meta for type: $requestedType")
|
||||
}
|
||||
|
||||
// Try each candidate until we find meta.
|
||||
|
|
@ -161,8 +197,7 @@ class MetaRepositoryImpl @Inject constructor(
|
|||
TAG,
|
||||
"Meta fetch success addonId=${addon.id} type=$candidateType id=$id"
|
||||
)
|
||||
emit(NetworkResult.Success(meta))
|
||||
return@flow
|
||||
return NetworkResult.Success(meta)
|
||||
}
|
||||
Log.d(
|
||||
TAG,
|
||||
|
|
@ -170,16 +205,23 @@ class MetaRepositoryImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Meta fetch failed addonId=${addon.id} type=$candidateType id=$id code=${result.code} message=${result.message}"
|
||||
)
|
||||
if (isBenignMetaFetchFailure(result.message)) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Meta fetch cancelled/short-circuited addonId=${addon.id} type=$candidateType id=$id message=${result.message}"
|
||||
)
|
||||
} else {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Meta fetch failed addonId=${addon.id} type=$candidateType id=$id code=${result.code} message=${result.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
NetworkResult.Loading -> { /* no-op */ }
|
||||
}
|
||||
}
|
||||
|
||||
emit(NetworkResult.Error("Meta not found in any addon"))
|
||||
return NetworkResult.Error("Meta not found in any addon")
|
||||
}
|
||||
|
||||
private fun buildMetaUrl(baseUrl: String, type: String, id: String): String {
|
||||
|
|
@ -220,6 +262,15 @@ class MetaRepositoryImpl @Inject constructor(
|
|||
private fun encodePathSegment(value: String): String {
|
||||
return URLEncoder.encode(value, "UTF-8").replace("+", "%20")
|
||||
}
|
||||
|
||||
private fun isBenignMetaFetchFailure(message: String?): Boolean {
|
||||
val normalized = message?.lowercase().orEmpty()
|
||||
return "flow was aborted" in normalized ||
|
||||
"no more elements needed" in normalized ||
|
||||
"child of the scoped flow was cancelled" in normalized ||
|
||||
"standalonecoroutine was cancelled" in normalized ||
|
||||
"job was cancelled" in normalized
|
||||
}
|
||||
|
||||
override fun clearCache() {
|
||||
metaCache.clear()
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ class HomeViewModel @Inject constructor(
|
|||
private const val MAX_NEXT_UP_LOOKUPS = 24
|
||||
private const val MAX_NEXT_UP_CONCURRENCY = 4
|
||||
private const val MAX_CATALOG_LOAD_CONCURRENCY = 4
|
||||
internal const val STARTUP_GRACE_PERIOD_MS = 3000L
|
||||
internal const val MAX_INITIAL_HOME_CATALOGS = 8
|
||||
internal const val DEFERRED_HOME_CATALOG_DELAY_MS = 2500L
|
||||
internal const val DEFERRED_HOME_CATALOG_STAGGER_MS = 120L
|
||||
internal const val EXTERNAL_META_PREFETCH_FOCUS_DEBOUNCE_MS = 220L
|
||||
internal const val MAX_POSTER_STATUS_OBSERVERS = 24
|
||||
}
|
||||
|
|
@ -88,6 +92,7 @@ class HomeViewModel @Inject constructor(
|
|||
internal var activeCatalogLoadSignature: String? = null
|
||||
internal var catalogLoadGeneration: Long = 0L
|
||||
internal var catalogsLoadInProgress: Boolean = false
|
||||
internal var deferredCatalogLoadJob: Job? = null
|
||||
internal data class TruncatedRowCacheEntry(
|
||||
val sourceRow: CatalogRow,
|
||||
val truncatedRow: CatalogRow
|
||||
|
|
@ -101,6 +106,10 @@ class HomeViewModel @Inject constructor(
|
|||
internal var trailerPreviewRequestVersion: Long = 0L
|
||||
internal var currentTmdbSettings: TmdbSettings = TmdbSettings()
|
||||
internal var heroEnrichmentJob: Job? = null
|
||||
internal var activeHeroEnrichmentSignature: String? = null
|
||||
internal var continueWatchingEnrichmentJob: Job? = null
|
||||
internal var activeContinueWatchingEnrichmentSignature: String? = null
|
||||
internal var lastContinueWatchingEnrichmentSignature: String? = null
|
||||
internal var lastHeroEnrichmentSignature: String? = null
|
||||
internal var lastHeroEnrichedItems: List<MetaPreview> = emptyList()
|
||||
internal val prefetchedExternalMetaIds = Collections.synchronizedSet(mutableSetOf<String>())
|
||||
|
|
@ -129,8 +138,9 @@ class HomeViewModel @Inject constructor(
|
|||
loadContinueWatching()
|
||||
observeInstalledAddons()
|
||||
viewModelScope.launch {
|
||||
delay(3000)
|
||||
delay(STARTUP_GRACE_PERIOD_MS)
|
||||
startupGracePeriodActive = false
|
||||
scheduleUpdateCatalogRows()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -293,6 +303,8 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
override fun onCleared() {
|
||||
heroEnrichmentJob?.cancel()
|
||||
continueWatchingEnrichmentJob?.cancel()
|
||||
posterStatusReconcileJob?.cancel()
|
||||
cancelInFlightCatalogLoads()
|
||||
posterLibraryObserverJobs.values.forEach { it.cancel() }
|
||||
|
|
|
|||
|
|
@ -142,10 +142,15 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline(
|
|||
}
|
||||
.map { catalog -> addon to catalog }
|
||||
}
|
||||
pendingCatalogLoads = catalogsToLoad.size
|
||||
catalogsToLoad.forEach { (addon, catalog) ->
|
||||
val orderedCatalogsToLoad = orderCatalogLoads(catalogsToLoad)
|
||||
val initialCatalogsToLoad = selectInitialCatalogLoads(orderedCatalogsToLoad)
|
||||
val deferredCatalogsToLoad = orderedCatalogsToLoad.filterNot { it in initialCatalogsToLoad }
|
||||
|
||||
pendingCatalogLoads = initialCatalogsToLoad.size
|
||||
initialCatalogsToLoad.forEach { (addon, catalog) ->
|
||||
loadCatalogPipeline(addon, catalog, generation)
|
||||
}
|
||||
scheduleDeferredCatalogLoads(deferredCatalogsToLoad, generation)
|
||||
} catch (e: Exception) {
|
||||
catalogsLoadInProgress = false
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
|
|
@ -224,6 +229,46 @@ internal fun HomeViewModel.loadCatalogPipeline(
|
|||
registerCatalogLoadJob(loadJob)
|
||||
}
|
||||
|
||||
private fun HomeViewModel.orderCatalogLoads(
|
||||
catalogsToLoad: List<Pair<Addon, CatalogDescriptor>>
|
||||
): List<Pair<Addon, CatalogDescriptor>> {
|
||||
val orderIndexByKey = catalogOrder.withIndex().associate { (index, key) -> key to index }
|
||||
return catalogsToLoad.sortedBy { (addon, catalog) ->
|
||||
orderIndexByKey[catalogKey(addon.id, catalog.apiType, catalog.id)] ?: Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
private fun HomeViewModel.selectInitialCatalogLoads(
|
||||
orderedCatalogsToLoad: List<Pair<Addon, CatalogDescriptor>>
|
||||
): List<Pair<Addon, CatalogDescriptor>> {
|
||||
val preferredCatalogs = orderedCatalogsToLoad.filterNot { (_, catalog) ->
|
||||
catalog.apiType.equals("other", ignoreCase = true)
|
||||
}
|
||||
return preferredCatalogs.take(HomeViewModel.MAX_INITIAL_HOME_CATALOGS)
|
||||
}
|
||||
|
||||
private fun HomeViewModel.scheduleDeferredCatalogLoads(
|
||||
deferredCatalogsToLoad: List<Pair<Addon, CatalogDescriptor>>,
|
||||
generation: Long
|
||||
) {
|
||||
deferredCatalogLoadJob?.cancel()
|
||||
deferredCatalogLoadJob = null
|
||||
|
||||
if (deferredCatalogsToLoad.isEmpty()) return
|
||||
|
||||
deferredCatalogLoadJob = viewModelScope.launch {
|
||||
delay(HomeViewModel.DEFERRED_HOME_CATALOG_DELAY_MS)
|
||||
deferredCatalogsToLoad.forEachIndexed { index, (addon, catalog) ->
|
||||
if (generation != catalogLoadGeneration) return@launch
|
||||
pendingCatalogLoads += 1
|
||||
loadCatalogPipeline(addon, catalog, generation)
|
||||
if (index < deferredCatalogsToLoad.lastIndex) {
|
||||
delay(HomeViewModel.DEFERRED_HOME_CATALOG_STAGGER_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun HomeViewModel.loadMoreCatalogItemsPipeline(catalogId: String, addonId: String, type: String) {
|
||||
val key = catalogKey(addonId = addonId, type = type, catalogId = catalogId)
|
||||
val currentRow = catalogsMap[key] ?: return
|
||||
|
|
@ -417,36 +462,52 @@ internal suspend fun HomeViewModel.updateCatalogRowsPipeline() {
|
|||
(tmdbSettings.useArtwork || tmdbSettings.useBasicInfo || tmdbSettings.useDetails)
|
||||
|
||||
if (shouldUseEnrichedHeroItems && baseHeroItems.isNotEmpty()) {
|
||||
heroEnrichmentJob?.cancel()
|
||||
heroEnrichmentJob = viewModelScope.launch {
|
||||
val enrichmentSignature = heroEnrichmentSignaturePipeline(baseHeroItems, tmdbSettings)
|
||||
if (lastHeroEnrichmentSignature == enrichmentSignature) {
|
||||
val cached = lastHeroEnrichedItems
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
heroItems = if (state.heroItems == cached) state.heroItems else cached,
|
||||
gridItems = if (currentLayout == HomeLayout.GRID) {
|
||||
val enrichedGrid = replaceGridHeroItemsPipeline(state.gridItems, cached)
|
||||
if (state.gridItems == enrichedGrid) state.gridItems else enrichedGrid
|
||||
} else state.gridItems
|
||||
)
|
||||
val enrichmentSignature = heroEnrichmentSignaturePipeline(baseHeroItems, tmdbSettings)
|
||||
if (lastHeroEnrichmentSignature == enrichmentSignature) {
|
||||
activeHeroEnrichmentSignature = null
|
||||
val cached = lastHeroEnrichedItems
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
heroItems = if (state.heroItems == cached) state.heroItems else cached,
|
||||
gridItems = if (currentLayout == HomeLayout.GRID) {
|
||||
val enrichedGrid = replaceGridHeroItemsPipeline(state.gridItems, cached)
|
||||
if (state.gridItems == enrichedGrid) state.gridItems else enrichedGrid
|
||||
} else state.gridItems
|
||||
)
|
||||
}
|
||||
} else if (activeHeroEnrichmentSignature != enrichmentSignature) {
|
||||
heroEnrichmentJob?.cancel()
|
||||
activeHeroEnrichmentSignature = enrichmentSignature
|
||||
heroEnrichmentJob = viewModelScope.launch {
|
||||
if (startupGracePeriodActive) {
|
||||
delay(HomeViewModel.STARTUP_GRACE_PERIOD_MS)
|
||||
}
|
||||
} else {
|
||||
val enrichedItems = enrichHeroItemsPipeline(baseHeroItems, tmdbSettings)
|
||||
lastHeroEnrichmentSignature = enrichmentSignature
|
||||
lastHeroEnrichedItems = enrichedItems
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
heroItems = if (state.heroItems == enrichedItems) state.heroItems else enrichedItems,
|
||||
gridItems = if (currentLayout == HomeLayout.GRID) {
|
||||
val enrichedGrid = replaceGridHeroItemsPipeline(state.gridItems, enrichedItems)
|
||||
if (state.gridItems == enrichedGrid) state.gridItems else enrichedGrid
|
||||
} else state.gridItems
|
||||
)
|
||||
if (startupGracePeriodActive) {
|
||||
activeHeroEnrichmentSignature = null
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val enrichedItems = enrichHeroItemsPipeline(baseHeroItems, tmdbSettings)
|
||||
lastHeroEnrichmentSignature = enrichmentSignature
|
||||
lastHeroEnrichedItems = enrichedItems
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
heroItems = if (state.heroItems == enrichedItems) state.heroItems else enrichedItems,
|
||||
gridItems = if (currentLayout == HomeLayout.GRID) {
|
||||
val enrichedGrid = replaceGridHeroItemsPipeline(state.gridItems, enrichedItems)
|
||||
if (state.gridItems == enrichedGrid) state.gridItems else enrichedGrid
|
||||
} else state.gridItems
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (activeHeroEnrichmentSignature == enrichmentSignature) {
|
||||
activeHeroEnrichmentSignature = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
activeHeroEnrichmentSignature = null
|
||||
lastHeroEnrichmentSignature = null
|
||||
lastHeroEnrichedItems = emptyList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ internal fun HomeViewModel.cancelInFlightCatalogLoads() {
|
|||
activeCatalogLoadJobs.toList().also { activeCatalogLoadJobs.clear() }
|
||||
}
|
||||
jobsToCancel.forEach { it.cancel() }
|
||||
deferredCatalogLoadJob?.cancel()
|
||||
deferredCatalogLoadJob = null
|
||||
}
|
||||
|
||||
internal fun HomeViewModel.rebuildCatalogOrder(addons: List<Addon>) {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import com.nuvio.tv.domain.model.Video
|
|||
import com.nuvio.tv.domain.model.WatchProgress
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
|
@ -34,6 +36,8 @@ import java.util.Locale
|
|||
private const val CW_MAX_RECENT_PROGRESS_ITEMS = 300
|
||||
private const val CW_MAX_NEXT_UP_LOOKUPS = 24
|
||||
private const val CW_MAX_NEXT_UP_CONCURRENCY = 4
|
||||
private const val CW_INITIAL_IN_PROGRESS_DETAIL_LIMIT = 4
|
||||
private const val CW_DEFERRED_IN_PROGRESS_DETAILS_DELAY_MS = 1200L
|
||||
|
||||
private data class ContinueWatchingSettingsSnapshot(
|
||||
val items: List<WatchProgress>,
|
||||
|
|
@ -68,7 +72,9 @@ internal fun HomeViewModel.loadContinueWatchingPipeline() {
|
|||
dismissedNextUp = dismissedNextUp,
|
||||
showUnairedNextUp = showUnairedNextUp
|
||||
)
|
||||
}.collectLatest { snapshot ->
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { snapshot ->
|
||||
val items = snapshot.items
|
||||
val daysCap = snapshot.daysCap
|
||||
val dismissedNextUp = snapshot.dismissedNextUp
|
||||
|
|
@ -112,17 +118,111 @@ internal fun HomeViewModel.loadContinueWatchingPipeline() {
|
|||
}
|
||||
|
||||
// Then enrich Next Up and item details in background.
|
||||
enrichContinueWatchingProgressively(
|
||||
scheduleContinueWatchingEnrichmentPipeline(
|
||||
allProgress = recentItems,
|
||||
inProgressItems = inProgressOnly,
|
||||
dismissedNextUp = dismissedNextUp,
|
||||
showUnairedNextUp = showUnairedNextUp
|
||||
)
|
||||
enrichInProgressEpisodeDetailsProgressively(inProgressOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun HomeViewModel.scheduleContinueWatchingEnrichmentPipeline(
|
||||
allProgress: List<WatchProgress>,
|
||||
inProgressItems: List<ContinueWatchingItem.InProgress>,
|
||||
dismissedNextUp: Set<String>,
|
||||
showUnairedNextUp: Boolean
|
||||
) {
|
||||
val enrichmentSignature = buildContinueWatchingEnrichmentSignature(
|
||||
allProgress = allProgress,
|
||||
inProgressItems = inProgressItems,
|
||||
dismissedNextUp = dismissedNextUp,
|
||||
showUnairedNextUp = showUnairedNextUp
|
||||
)
|
||||
if (enrichmentSignature == lastContinueWatchingEnrichmentSignature ||
|
||||
enrichmentSignature == activeContinueWatchingEnrichmentSignature
|
||||
) {
|
||||
return
|
||||
}
|
||||
continueWatchingEnrichmentJob?.cancel()
|
||||
activeContinueWatchingEnrichmentSignature = enrichmentSignature
|
||||
continueWatchingEnrichmentJob = viewModelScope.launch {
|
||||
if (startupGracePeriodActive) {
|
||||
delay(HomeViewModel.STARTUP_GRACE_PERIOD_MS)
|
||||
}
|
||||
if (startupGracePeriodActive) {
|
||||
activeContinueWatchingEnrichmentSignature = null
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
enrichContinueWatchingProgressively(
|
||||
allProgress = allProgress,
|
||||
inProgressItems = inProgressItems,
|
||||
dismissedNextUp = dismissedNextUp,
|
||||
showUnairedNextUp = showUnairedNextUp
|
||||
)
|
||||
val priorityInProgressItems = inProgressItems.take(CW_INITIAL_IN_PROGRESS_DETAIL_LIMIT)
|
||||
val deferredInProgressItems = inProgressItems.drop(CW_INITIAL_IN_PROGRESS_DETAIL_LIMIT)
|
||||
enrichInProgressEpisodeDetailsProgressively(priorityInProgressItems)
|
||||
if (deferredInProgressItems.isNotEmpty()) {
|
||||
delay(CW_DEFERRED_IN_PROGRESS_DETAILS_DELAY_MS)
|
||||
enrichInProgressEpisodeDetailsProgressively(deferredInProgressItems)
|
||||
}
|
||||
lastContinueWatchingEnrichmentSignature = enrichmentSignature
|
||||
} finally {
|
||||
if (activeContinueWatchingEnrichmentSignature == enrichmentSignature) {
|
||||
activeContinueWatchingEnrichmentSignature = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildContinueWatchingEnrichmentSignature(
|
||||
allProgress: List<WatchProgress>,
|
||||
inProgressItems: List<ContinueWatchingItem.InProgress>,
|
||||
dismissedNextUp: Set<String>,
|
||||
showUnairedNextUp: Boolean
|
||||
): String {
|
||||
val allProgressSignature = allProgress.joinToString(separator = "|") { progress ->
|
||||
listOf(
|
||||
progress.contentId,
|
||||
progress.contentType,
|
||||
progress.videoId,
|
||||
progress.season,
|
||||
progress.episode,
|
||||
progress.position,
|
||||
progress.duration,
|
||||
progress.lastWatched,
|
||||
progress.progressPercent,
|
||||
progress.source
|
||||
).joinToString(separator = ":")
|
||||
}
|
||||
val inProgressSignature = inProgressItems.joinToString(separator = "|") { item ->
|
||||
val progress = item.progress
|
||||
listOf(
|
||||
progress.contentId,
|
||||
progress.contentType,
|
||||
progress.videoId,
|
||||
progress.season,
|
||||
progress.episode,
|
||||
progress.position,
|
||||
progress.duration,
|
||||
progress.lastWatched
|
||||
).joinToString(separator = ":")
|
||||
}
|
||||
val dismissedSignature = dismissedNextUp.toSortedSet().joinToString(separator = "|")
|
||||
return buildString {
|
||||
append(showUnairedNextUp)
|
||||
append("::")
|
||||
append(dismissedSignature)
|
||||
append("::")
|
||||
append(inProgressSignature)
|
||||
append("::")
|
||||
append(allProgressSignature)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deduplicateInProgress(items: List<WatchProgress>): List<WatchProgress> {
|
||||
val (series, nonSeries) = items.partition { isSeriesTypeCW(it.contentType) }
|
||||
val latestPerShow = series
|
||||
|
|
@ -629,7 +729,11 @@ private suspend fun HomeViewModel.resolveMetaForProgress(
|
|||
if (progress.contentId.startsWith("trakt:")) add(progress.contentId.substringAfter(':'))
|
||||
}.distinct()
|
||||
|
||||
val typeCandidates = listOf(progress.contentType, "series", "tv").distinct()
|
||||
val typeCandidates = when {
|
||||
isSeriesTypeCW(progress.contentType) -> listOf(progress.contentType, "series", "tv").distinct()
|
||||
progress.contentType.equals("movie", ignoreCase = true) -> listOf("movie")
|
||||
else -> listOf(progress.contentType).filter { it.isNotBlank() }.distinct()
|
||||
}
|
||||
val resolved = run {
|
||||
var meta: Meta? = null
|
||||
for (type in typeCandidates) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.nuvio.tv.domain.model.HomeLayout
|
|||
import com.nuvio.tv.domain.model.Meta
|
||||
import com.nuvio.tv.domain.model.MetaPreview
|
||||
import com.nuvio.tv.domain.model.TmdbSettings
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.async
|
||||
|
|
@ -426,6 +427,8 @@ internal suspend fun HomeViewModel.enrichHeroItemsPipeline(
|
|||
}
|
||||
|
||||
enriched
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(HomeViewModel.TAG, "Hero enrichment failed for ${item.id}: ${e.message}")
|
||||
item
|
||||
|
|
|
|||
Loading…
Reference in a new issue