mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
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:
@@ -41,7 +41,6 @@ fun TvEpisodeScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val episode = viewModel.episode.collectAsStateWithLifecycle()
|
val episode = viewModel.episode.collectAsStateWithLifecycle()
|
||||||
val seriesTitle = viewModel.seriesTitle.collectAsStateWithLifecycle()
|
|
||||||
val selectedEpisode = episode.value
|
val selectedEpisode = episode.value
|
||||||
|
|
||||||
if (selectedEpisode == null) {
|
if (selectedEpisode == null) {
|
||||||
@@ -51,7 +50,7 @@ fun TvEpisodeScreen(
|
|||||||
|
|
||||||
TvEpisodeScreenContent(
|
TvEpisodeScreenContent(
|
||||||
episode = selectedEpisode,
|
episode = selectedEpisode,
|
||||||
seriesTitle = seriesTitle.value,
|
seriesTitle = selectedEpisode.seriesName,
|
||||||
onPlay = remember(selectedEpisode.id, navigationManager) {
|
onPlay = remember(selectedEpisode.id, navigationManager) {
|
||||||
{
|
{
|
||||||
navigationManager.navigate(
|
navigationManager.navigate(
|
||||||
|
|||||||
@@ -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) {
|
override suspend fun loadSeasons(seriesId: UUID) {
|
||||||
runOnlineOrOfflineNoOp(
|
runOnlineOrOfflineNoOp(
|
||||||
onlineAction = { onlineRepository.loadSeasons(seriesId) },
|
onlineAction = { onlineRepository.loadSeasons(seriesId) },
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ interface LocalMediaRepository : MediaMetadataUpdater {
|
|||||||
suspend fun getMovie(id: UUID): Flow<Movie?>
|
suspend fun getMovie(id: UUID): Flow<Movie?>
|
||||||
suspend fun getSeries(id: UUID): Flow<Series?>
|
suspend fun getSeries(id: UUID): Flow<Series?>
|
||||||
suspend fun getEpisode(id: UUID): Flow<Episode?>
|
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 loadSeasons(seriesId: UUID)
|
||||||
suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID)
|
suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -47,17 +46,6 @@ class EpisodeScreenViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
.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)
|
private val _downloadState = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
|
||||||
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
|
val downloadState: StateFlow<DownloadState> = _downloadState.asStateFlow()
|
||||||
|
|
||||||
@@ -76,6 +64,9 @@ class EpisodeScreenViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun selectEpisode(episode: EpisodeDto) {
|
fun selectEpisode(episode: EpisodeDto) {
|
||||||
_episode.value = episode
|
_episode.value = episode
|
||||||
|
viewModelScope.launch {
|
||||||
|
mediaCatalogReader(episode.offline).loadEpisode(episode.id)
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mediaDownloadManager.observeDownloadState(episode.id.toString()).collect {
|
mediaDownloadManager.observeDownloadState(episode.id.toString()).collect {
|
||||||
_downloadState.value = it
|
_downloadState.value = it
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ class MovieScreenViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun selectMovie(movie: MovieDto) {
|
fun selectMovie(movie: MovieDto) {
|
||||||
_movie.value = movie
|
_movie.value = movie
|
||||||
|
viewModelScope.launch {
|
||||||
|
mediaCatalogReader(movie.offline).loadMovie(movie.id)
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mediaDownloadManager.observeDownloadState(movie.id.toString()).collect {
|
mediaDownloadManager.observeDownloadState(movie.id.toString()).collect {
|
||||||
_downloadState.value = it
|
_downloadState.value = it
|
||||||
|
|||||||
@@ -195,7 +195,9 @@ class SeriesViewModel @Inject constructor(
|
|||||||
fun selectSeries(series: SeriesDto) {
|
fun selectSeries(series: SeriesDto) {
|
||||||
_series.value = series
|
_series.value = series
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mediaCatalogReader(series.offline).loadSeasons(series.id)
|
val mediaCatalogReader = mediaCatalogReader(series.offline)
|
||||||
|
mediaCatalogReader.loadSeries(series.id)
|
||||||
|
mediaCatalogReader.loadSeasons(series.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,8 +169,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(error = dataErrorMessage) }
|
_uiState.update { it.copy(error = dataErrorMessage) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
//TODO hack to preload the series media
|
mediaCatalogReader.loadEpisode(UUID.fromString(id))
|
||||||
mediaCatalogReader.getEpisode(UUID.fromString(id))
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
playerManager.play(uuid)
|
playerManager.play(uuid)
|
||||||
@@ -243,6 +242,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private suspend fun PlayableMedia.toPlaylistElementUiModel(currentMediaId: UUID?): PlaylistElementUiModel? {
|
private suspend fun PlayableMedia.toPlaylistElementUiModel(currentMediaId: UUID?): PlaylistElementUiModel? {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is PlayableMedia.Movie -> {
|
is PlayableMedia.Movie -> {
|
||||||
|
mediaCatalogReader.loadMovie(id)
|
||||||
val movie = mediaCatalogReader.getMovie(id).first()
|
val movie = mediaCatalogReader.getMovie(id).first()
|
||||||
if (movie == null) {
|
if (movie == null) {
|
||||||
Timber.tag(TAG).e("Movie not found for playlist item: $id")
|
Timber.tag(TAG).e("Movie not found for playlist item: $id")
|
||||||
@@ -261,6 +261,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is PlayableMedia.Series -> {
|
is PlayableMedia.Series -> {
|
||||||
|
mediaCatalogReader.loadSeries(id)
|
||||||
val series = mediaCatalogReader.getSeries(id).first()
|
val series = mediaCatalogReader.getSeries(id).first()
|
||||||
if (series == null) {
|
if (series == null) {
|
||||||
Timber.tag(TAG).e("Series not found for playlist item: $id")
|
Timber.tag(TAG).e("Series not found for playlist item: $id")
|
||||||
@@ -279,6 +280,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is PlayableMedia.Episode -> {
|
is PlayableMedia.Episode -> {
|
||||||
|
mediaCatalogReader.loadEpisode(id)
|
||||||
val episode = mediaCatalogReader.getEpisode(id).first()
|
val episode = mediaCatalogReader.getEpisode(id).first()
|
||||||
if (episode == null) {
|
if (episode == null) {
|
||||||
Timber.tag(TAG).e("Episode not found for playlist item: $id")
|
Timber.tag(TAG).e("Episode not found for playlist item: $id")
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
@@ -48,48 +47,70 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
|||||||
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
|
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
|
||||||
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
|
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
|
||||||
|
|
||||||
override suspend fun getMovie(id: UUID): Flow<Movie?> {
|
override suspend fun getMovie(id: UUID): Flow<Movie?> =
|
||||||
if (!moviesState.value.containsKey(id)) {
|
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 ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.MOVIE) {
|
if (item.type != BaseItemKind.MOVIE) {
|
||||||
Timber.tag(TAG).d("Item is not an movie: ${item.type}")
|
Timber.tag(TAG).d("Item is not an movie: ${item.type}")
|
||||||
return flowOf(null)
|
return
|
||||||
}
|
}
|
||||||
val movie = item.toMovie(serverUrl.first())
|
val movie = item.toMovie(serverUrl.first())
|
||||||
moviesState.update { current -> current + (movie.id to movie) }
|
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?> {
|
override suspend fun loadSeries(id: UUID) {
|
||||||
if (!seriesState.value.containsKey(id)) {
|
if (seriesState.value.containsKey(id)) return
|
||||||
|
try {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.SERIES) {
|
if (item.type != BaseItemKind.SERIES) {
|
||||||
Timber.tag(TAG).d("Item is not an series: ${item.type}")
|
Timber.tag(TAG).d("Item is not an series: ${item.type}")
|
||||||
return flowOf(null)
|
return
|
||||||
}
|
}
|
||||||
val series = item.toSeries(serverUrl.first())
|
val series = item.toSeries(serverUrl.first())
|
||||||
seriesState.update { current -> current + (series.id to series) }
|
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?> {
|
override suspend fun loadEpisode(id: UUID) {
|
||||||
if (!episodesState.value.containsKey(id)) {
|
if (episodesState.value.containsKey(id)) return
|
||||||
|
try {
|
||||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||||
if (item.type != BaseItemKind.EPISODE) {
|
if (item.type != BaseItemKind.EPISODE) {
|
||||||
Timber.tag(TAG).d("Item is not an episode: ${item.type}")
|
Timber.tag(TAG).d("Item is not an episode: ${item.type}")
|
||||||
return flowOf(null)
|
return
|
||||||
}
|
}
|
||||||
val episode = item.toEpisode(serverUrl.first())
|
val episode = item.toEpisode(serverUrl.first())
|
||||||
episodesState.update { current -> current + (episode.id to episode) }
|
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>) {
|
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) {
|
private suspend fun loadSeasonsInternal(seriesId: UUID) {
|
||||||
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
|
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ class OfflineLocalMediaRepository @Inject constructor(
|
|||||||
return episodes.map { it[id] }
|
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) {
|
override suspend fun loadSeasons(seriesId: UUID) {
|
||||||
// Offline series content is already emitted with its saved seasons.
|
// Offline series content is already emitted with its saved seasons.
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user