refactor(core): separate data loading from repository reads

Move implicit loading side effects out of getX methods into explicit loadX methods, making data flow predictable. Remove seriesTitle StateFlow in favor of direct seriesName access. Add proper error handling to repository load operations.
This commit is contained in:
2026-06-20 09:27:25 +00:00
parent 80f25c6dea
commit 5a98f3d2e7
9 changed files with 86 additions and 42 deletions

View File

@@ -41,7 +41,6 @@ fun TvEpisodeScreen(
}
val episode = viewModel.episode.collectAsStateWithLifecycle()
val seriesTitle = viewModel.seriesTitle.collectAsStateWithLifecycle()
val selectedEpisode = episode.value
if (selectedEpisode == null) {
@@ -51,7 +50,7 @@ fun TvEpisodeScreen(
TvEpisodeScreenContent(
episode = selectedEpisode,
seriesTitle = seriesTitle.value,
seriesTitle = selectedEpisode.seriesName,
onPlay = remember(selectedEpisode.id, navigationManager) {
{
navigationManager.navigate(

View File

@@ -67,6 +67,27 @@ class CompositeLocalMediaRepository @Inject constructor(
)
}
override suspend fun loadMovie(id: UUID) {
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadMovie(id) },
offlineAction = { offlineRepository.loadMovie(id) },
)
}
override suspend fun loadSeries(id: UUID) {
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadSeries(id) },
offlineAction = { offlineRepository.loadSeries(id) },
)
}
override suspend fun loadEpisode(id: UUID) {
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadEpisode(id) },
offlineAction = { offlineRepository.loadEpisode(id) },
)
}
override suspend fun loadSeasons(seriesId: UUID) {
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadSeasons(seriesId) },

View File

@@ -14,6 +14,9 @@ interface LocalMediaRepository : MediaMetadataUpdater {
suspend fun getMovie(id: UUID): Flow<Movie?>
suspend fun getSeries(id: UUID): Flow<Series?>
suspend fun getEpisode(id: UUID): Flow<Episode?>
suspend fun loadMovie(id: UUID)
suspend fun loadSeries(id: UUID)
suspend fun loadEpisode(id: UUID)
suspend fun loadSeasons(seriesId: UUID)
suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID)
}

View File

@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -47,17 +46,6 @@ class EpisodeScreenViewModel @Inject constructor(
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val seriesTitle: StateFlow<String?> = _episode
.flatMapLatest { episode ->
if (episode == null) {
flowOf(null)
} else {
mediaCatalogReader(episode.offline).getSeries(episode.seriesId).map { it?.name }
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
@@ -76,6 +64,9 @@ class EpisodeScreenViewModel @Inject constructor(
fun selectEpisode(episode: EpisodeDto) {
_episode.value = episode
viewModelScope.launch {
mediaCatalogReader(episode.offline).loadEpisode(episode.id)
}
viewModelScope.launch {
mediaDownloadManager.observeDownloadState(episode.id.toString()).collect {
_downloadState.value = it

View File

@@ -63,6 +63,9 @@ class MovieScreenViewModel @Inject constructor(
fun selectMovie(movie: MovieDto) {
_movie.value = movie
viewModelScope.launch {
mediaCatalogReader(movie.offline).loadMovie(movie.id)
}
viewModelScope.launch {
mediaDownloadManager.observeDownloadState(movie.id.toString()).collect {
_downloadState.value = it

View File

@@ -195,7 +195,9 @@ class SeriesViewModel @Inject constructor(
fun selectSeries(series: SeriesDto) {
_series.value = series
viewModelScope.launch {
mediaCatalogReader(series.offline).loadSeasons(series.id)
val mediaCatalogReader = mediaCatalogReader(series.offline)
mediaCatalogReader.loadSeries(series.id)
mediaCatalogReader.loadSeasons(series.id)
}
}

View File

@@ -169,8 +169,7 @@ class PlayerViewModel @Inject constructor(
_uiState.update { it.copy(error = dataErrorMessage) }
return
}
//TODO hack to preload the series media
mediaCatalogReader.getEpisode(UUID.fromString(id))
mediaCatalogReader.loadEpisode(UUID.fromString(id))
viewModelScope.launch {
runCatching {
playerManager.play(uuid)
@@ -243,6 +242,7 @@ class PlayerViewModel @Inject constructor(
private suspend fun PlayableMedia.toPlaylistElementUiModel(currentMediaId: UUID?): PlaylistElementUiModel? {
return when (this) {
is PlayableMedia.Movie -> {
mediaCatalogReader.loadMovie(id)
val movie = mediaCatalogReader.getMovie(id).first()
if (movie == null) {
Timber.tag(TAG).e("Movie not found for playlist item: $id")
@@ -261,6 +261,7 @@ class PlayerViewModel @Inject constructor(
}
is PlayableMedia.Series -> {
mediaCatalogReader.loadSeries(id)
val series = mediaCatalogReader.getSeries(id).first()
if (series == null) {
Timber.tag(TAG).e("Series not found for playlist item: $id")
@@ -279,6 +280,7 @@ class PlayerViewModel @Inject constructor(
}
is PlayableMedia.Episode -> {
mediaCatalogReader.loadEpisode(id)
val episode = mediaCatalogReader.getEpisode(id).first()
if (episode == null) {
Timber.tag(TAG).e("Episode not found for playlist item: $id")

View File

@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -48,48 +47,70 @@ class InMemoryLocalMediaRepository @Inject constructor(
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
override suspend fun getMovie(id: UUID): Flow<Movie?> {
if (!moviesState.value.containsKey(id)) {
override suspend fun getMovie(id: UUID): Flow<Movie?> =
moviesState.map { it[id] }.distinctUntilChanged()
override suspend fun getSeries(id: UUID): Flow<Series?> =
seriesState.map { it[id] }.distinctUntilChanged()
override suspend fun getEpisode(id: UUID): Flow<Episode?> =
episodesState.map { it[id] }.distinctUntilChanged()
override suspend fun loadMovie(id: UUID) {
if (moviesState.value.containsKey(id)) return
try {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.MOVIE) {
Timber.tag(TAG).d("Item is not an movie: ${item.type}")
return flowOf(null)
return
}
val movie = item.toMovie(serverUrl.first())
moviesState.update { current -> current + (movie.id to movie) }
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).e(error, "Failed to load movie $id")
throw error
}
return moviesState.map { it[id] }.distinctUntilChanged()
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
if (!seriesState.value.containsKey(id)) {
override suspend fun loadSeries(id: UUID) {
if (seriesState.value.containsKey(id)) return
try {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.SERIES) {
Timber.tag(TAG).d("Item is not an series: ${item.type}")
return flowOf(null)
return
}
val series = item.toSeries(serverUrl.first())
seriesState.update { current -> current + (series.id to series) }
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).e(error, "Failed to load series $id")
throw error
}
return seriesState.map { it[id] }.distinctUntilChanged()
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
if (!episodesState.value.containsKey(id)) {
override suspend fun loadEpisode(id: UUID) {
if (episodesState.value.containsKey(id)) return
try {
jellyfinApiClient.getItemInfo(id)?.let { item ->
if (item.type != BaseItemKind.EPISODE) {
Timber.tag(TAG).d("Item is not an episode: ${item.type}")
return flowOf(null)
return
}
val episode = item.toEpisode(serverUrl.first())
episodesState.update { current -> current + (episode.id to episode) }
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).e(error, "Failed to load episode $id")
throw error
}
val episode = episodesState.value[id] ?: return flowOf(null)
preloadSeasonsForEpisode(episode.seriesId)
return episodesState.map { it[id] }.distinctUntilChanged()
}
fun upsertMovies(movies: List<Movie>) {
@@ -115,16 +136,6 @@ class InMemoryLocalMediaRepository @Inject constructor(
}
}
private suspend fun preloadSeasonsForEpisode(seriesId: UUID) {
try {
loadSeasonsInternal(seriesId)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).w(error, "Unable to preload seasons for episode series $seriesId")
}
}
private suspend fun loadSeasonsInternal(seriesId: UUID) {
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }

View File

@@ -46,6 +46,18 @@ class OfflineLocalMediaRepository @Inject constructor(
return episodes.map { it[id] }
}
override suspend fun loadMovie(id: UUID) {
// Offline movie content is already persisted.
}
override suspend fun loadSeries(id: UUID) {
// Offline series content is already persisted.
}
override suspend fun loadEpisode(id: UUID) {
// Offline episode content is already persisted.
}
override suspend fun loadSeasons(seriesId: UUID) {
// Offline series content is already emitted with its saved seasons.
}