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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.navigation.SeriesDto
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Season
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.TvMediaDetailBodyBox
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.waiting.PurefinWaitingScreen
import java.util.UUID
import androidx.tv.material3.Text as TvText
@Composable
fun TvSeriesScreen(
@@ -54,8 +54,8 @@ fun TvSeriesScreen(
if (seriesData != null) {
TvSeriesScreenContent(
series = seriesData,
selectSeason = viewModel::selectSeason,
onPlayEpisode = viewModel::onPlayEpisode,
onLoadSeasonEpisodes = viewModel::loadSeasonEpisodes,
focusedSeasonId = focusedSeasonId,
focusedEpisodeId = focusedEpisodeId,
modifier = modifier
@@ -68,8 +68,8 @@ fun TvSeriesScreen(
@Composable
internal fun TvSeriesScreenContent(
series: Series,
selectSeason: (UUID, UUID) -> Unit,
onPlayEpisode: (UUID) -> Unit,
onLoadSeasonEpisodes: (UUID, UUID) -> Unit = { _, _ -> },
focusedSeasonId: UUID? = null,
focusedEpisodeId: UUID? = null,
modifier: Modifier = Modifier,
@@ -96,7 +96,7 @@ internal fun TvSeriesScreenContent(
initialFocusSeason.episodeCount > 0
LaunchedEffect(series.id, selectedSeason?.id) {
selectedSeason?.let { onLoadSeasonEpisodes(series.id, it.id) }
selectedSeason?.let { selectSeason(series.id, it.id) }
}
TvMediaDetailScaffold(

View File

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

View File

@@ -5,9 +5,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.Offline
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.MediaDownloadController
import hu.bbara.purefin.core.data.MediaMetadataUpdater
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.NavigationManager
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) {
viewModelScope.launch {
val mediaCatalogReader = selectedMediaCatalogReader()
@@ -196,8 +190,14 @@ class SeriesViewModel @Inject constructor(
_series.value = series
viewModelScope.launch {
val mediaCatalogReader = mediaCatalogReader(series.offline)
mediaCatalogReader.loadSeries(series.id)
mediaCatalogReader.loadSeasons(series.id)
launch { mediaCatalogReader.loadSeries(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) {
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(
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
)
seriesState.update { it + (updatedSeries.id to updatedSeries) }
// Await the parallel loadSeries to populate the series entry.
// Precondition: seriesId refers to a real series (selectSeries always uses a
// SeriesDto from a real series list). If loadSeries returns without populating
// 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) {
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
if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
return
}
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
.map { it.toEpisode(serverUrl) }
seriesState.update { current ->
val currentSeries = current[seriesId] ?: return@update current
val updatedSeries = currentSeries.copy(