perf: coroutine cancellation.optimize addon manifest loading with shared flows

This commit is contained in:
tapframe 2026-03-06 07:04:25 +05:30
parent b31514e65e
commit 6e049fb02d
12 changed files with 345 additions and 75 deletions

1
.gitignore vendored
View file

@ -43,3 +43,4 @@ tv-samples
HOME_UI_OPTIMIZATION_NOTES.local.md
.kotlin/
bugs/
logs/

View file

@ -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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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