refactor(core/series): improve data loading reliability with concurrent initialization

This commit is contained in:
2026-06-20 12:18:29 +00:00
parent 5a98f3d2e7
commit 8a2702a7cb
4 changed files with 48 additions and 28 deletions

View File

@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -18,13 +17,13 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text as TvText
import hu.bbara.purefin.core.feature.content.series.SeriesViewModel import hu.bbara.purefin.core.feature.content.series.SeriesViewModel
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding import hu.bbara.purefin.ui.common.media.MediaDetailHorizontalPadding
import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
@@ -35,6 +34,7 @@ import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import java.util.UUID import java.util.UUID
import androidx.tv.material3.Text as TvText
@Composable @Composable
fun TvSeriesScreen( fun TvSeriesScreen(
@@ -54,8 +54,8 @@ fun TvSeriesScreen(
if (seriesData != null) { if (seriesData != null) {
TvSeriesScreenContent( TvSeriesScreenContent(
series = seriesData, series = seriesData,
selectSeason = viewModel::selectSeason,
onPlayEpisode = viewModel::onPlayEpisode, onPlayEpisode = viewModel::onPlayEpisode,
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
focusedSeasonId = focusedSeasonId, focusedSeasonId = focusedSeasonId,
focusedEpisodeId = focusedEpisodeId, focusedEpisodeId = focusedEpisodeId,
modifier = modifier modifier = modifier
@@ -68,8 +68,8 @@ fun TvSeriesScreen(
@Composable @Composable
internal fun TvSeriesScreenContent( internal fun TvSeriesScreenContent(
series: Series, series: Series,
selectSeason: (UUID, UUID) -> Unit,
onPlayEpisode: (UUID) -> Unit, onPlayEpisode: (UUID) -> Unit,
onLoadSeasonEpisodes: (UUID, UUID) -> Unit = { _, _ -> },
focusedSeasonId: UUID? = null, focusedSeasonId: UUID? = null,
focusedEpisodeId: UUID? = null, focusedEpisodeId: UUID? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -96,7 +96,7 @@ internal fun TvSeriesScreenContent(
initialFocusSeason.episodeCount > 0 initialFocusSeason.episodeCount > 0
LaunchedEffect(series.id, selectedSeason?.id) { LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) } selectedSeason?.let { selectSeason(series.id, it.id) }
} }
TvMediaDetailScaffold( TvMediaDetailScaffold(

View File

@@ -99,6 +99,7 @@ fun SeriesScreen(
SeriesScreenInternal( SeriesScreenInternal(
series = seriesData, series = seriesData,
selectSeason = viewModel::selectSeason,
seriesDownloadState = seriesDownloadState, seriesDownloadState = seriesDownloadState,
seasonDownloadState = seasonDownloadState, seasonDownloadState = seasonDownloadState,
isSmartDownloadEnabled = isSmartDownloadEnabled, isSmartDownloadEnabled = isSmartDownloadEnabled,
@@ -113,7 +114,6 @@ fun SeriesScreen(
} }
} }
}, },
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState, onObserveSeasonDownloadState = viewModel::observeSeasonDownloadState,
onBack = viewModel::onGoHome, onBack = viewModel::onGoHome,
onMarkAsWatched = viewModel::markAsWatched, onMarkAsWatched = viewModel::markAsWatched,
@@ -129,11 +129,11 @@ fun SeriesScreen(
@Composable @Composable
private fun SeriesScreenInternal( private fun SeriesScreenInternal(
series: Series, series: Series,
selectSeason: (UUID, UUID) -> Unit,
seriesDownloadState: DownloadState, seriesDownloadState: DownloadState,
seasonDownloadState: DownloadState, seasonDownloadState: DownloadState,
isSmartDownloadEnabled: Boolean, isSmartDownloadEnabled: Boolean,
onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit, onDownloadOptionSelected: (SeriesDownloadOption, Season) -> Unit,
onLoadSeasonEpisodes: (UUID, UUID) -> Unit,
onObserveSeasonDownloadState: (List<Episode>) -> Unit, onObserveSeasonDownloadState: (List<Episode>) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onMarkAsWatched: (Boolean) -> Unit = {}, onMarkAsWatched: (Boolean) -> Unit = {},
@@ -175,7 +175,7 @@ private fun SeriesScreenInternal(
} }
LaunchedEffect(series.id, selectedSeason?.id) { LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) } selectedSeason?.let { selectSeason(series.id, it.id) }
} }
LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) { LaunchedEffect(selectedSeason?.id, selectedSeason?.episodes) {

View File

@@ -5,9 +5,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.Offline import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.download.MediaDownloadController import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.navigation.EpisodeDto import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.Route
@@ -98,12 +98,6 @@ class SeriesViewModel @Inject constructor(
} }
} }
fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch {
selectedMediaCatalogReader().loadSeasonEpisodes(seriesId, seasonId)
}
}
fun downloadSeason(seriesId: UUID, seasonId: UUID) { fun downloadSeason(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch { viewModelScope.launch {
val mediaCatalogReader = selectedMediaCatalogReader() val mediaCatalogReader = selectedMediaCatalogReader()
@@ -196,8 +190,14 @@ class SeriesViewModel @Inject constructor(
_series.value = series _series.value = series
viewModelScope.launch { viewModelScope.launch {
val mediaCatalogReader = mediaCatalogReader(series.offline) val mediaCatalogReader = mediaCatalogReader(series.offline)
mediaCatalogReader.loadSeries(series.id) launch { mediaCatalogReader.loadSeries(series.id) }
mediaCatalogReader.loadSeasons(series.id) launch { mediaCatalogReader.loadSeasons(series.id) }
}
}
fun selectSeason(seriesId: UUID, seasonId: UUID) {
viewModelScope.launch {
selectedMediaCatalogReader().loadSeasonEpisodes(seriesId, seasonId)
} }
} }

View File

@@ -139,26 +139,46 @@ class InMemoryLocalMediaRepository @Inject constructor(
private suspend fun loadSeasonsInternal(seriesId: UUID) { private suspend fun loadSeasonsInternal(seriesId: UUID) {
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return } seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found") val seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
val updatedSeries = series.copy( // Await the parallel loadSeries to populate the series entry.
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() } // Precondition: seriesId refers to a real series (selectSeries always uses a
) // SeriesDto from a real series list). If loadSeries returns without populating
seriesState.update { it + (updatedSeries.id to updatedSeries) } // state (null item / wrong type) this suspends until cancelled by structured
// concurrency when a sibling load* call fails.
seriesState.first { it.containsKey(seriesId) }
seriesState.update { current ->
val existing = current[seriesId] ?: return@update current
if (existing.seasons.isNotEmpty()) return@update current
current + (seriesId to existing.copy(seasons = seasons))
}
} }
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) { override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
loadSeasons(seriesId) // Fast path: season already cached with episodes or known empty — skip the fetch.
seriesState.value[seriesId]?.seasons?.firstOrNull { it.id == seasonId }?.let { cached ->
if (cached.episodes.isNotEmpty() || cached.episodeCount == 0) return
}
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found") // Fetch first so this runs concurrently with loadSeries/loadSeasons when launched
// from selectSeries. Precondition: seasons are pre-loaded by selectSeries and
// seasonId is always a valid season from a real EpisodeDto in this path.
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
.map { it.toEpisode(serverUrl) }
// Await the season to be present in state (immediate when pre-loaded; waits for
// the parallel loadSeasons otherwise).
seriesState.first { it[seriesId]?.seasons?.any { it.id == seasonId } == true }
// Re-check guard after await: a concurrent call may have filled episodes already.
val series = seriesState.value[seriesId] ?: return
val season = series.seasons.firstOrNull { it.id == seasonId } ?: return val season = series.seasons.firstOrNull { it.id == seasonId } ?: return
if (season.episodes.isNotEmpty() || season.episodeCount == 0) { if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
return return
} }
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
.map { it.toEpisode(serverUrl) }
seriesState.update { current -> seriesState.update { current ->
val currentSeries = current[seriesId] ?: return@update current val currentSeries = current[seriesId] ?: return@update current
val updatedSeries = currentSeries.copy( val updatedSeries = currentSeries.copy(