feat: refactor media retrieval to use combined flows for episodes and continue watching

This commit is contained in:
2026-02-08 17:56:40 +01:00
parent f932507469
commit cc9a82a4cf
9 changed files with 113 additions and 217 deletions

View File

@@ -7,7 +7,10 @@ import hu.bbara.purefin.data.InMemoryMediaRepository
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.navigation.NavigationManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import javax.inject.Inject
@@ -18,8 +21,14 @@ class EpisodeScreenViewModel @Inject constructor(
private val navigationManager: NavigationManager,
): ViewModel() {
private val _episode = MutableStateFlow<Episode?>(null)
val episode = _episode.asStateFlow()
private val _episodeId = MutableStateFlow<UUID?>(null)
val episode: StateFlow<Episode?> = combine(
_episodeId,
mediaRepository.episodes
) { id, episodesMap ->
id?.let { episodesMap[it] }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
init {
viewModelScope.launch { mediaRepository.ensureReady() }
@@ -30,13 +39,7 @@ class EpisodeScreenViewModel @Inject constructor(
}
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
viewModelScope.launch {
_episode.value = mediaRepository.getEpisode(
seriesId = seriesId,
seasonId = seasonId,
episodeId = episodeId,
)
}
_episodeId.value = episodeId
}
}

View File

@@ -40,9 +40,10 @@ fun SeriesScreen(
val series = viewModel.series.collectAsState()
if (series.value != null) {
val seriesData = series.value
if (seriesData != null && seriesData.seasons.isNotEmpty()) {
SeriesScreenInternal(
series = series.value!!,
series = seriesData,
onBack = viewModel::onBack,
modifier = modifier
)

View File

@@ -60,9 +60,5 @@ class SeriesViewModel @Inject constructor(
fun selectSeries(seriesId: UUID) {
_seriesId.value = seriesId
// Ensure content is loaded from API if not cached
viewModelScope.launch {
mediaRepository.getSeriesWithContent(seriesId)
}
}
}

View File

@@ -19,16 +19,12 @@ import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -59,93 +55,54 @@ class HomePageViewModel @Inject constructor(
}
}
val continueWatching = mediaRepository.continueWatching
.mapLatest { list ->
withContext(Dispatchers.IO) {
list.map { media ->
val continueWatching = combine(
mediaRepository.continueWatching,
mediaRepository.movies,
mediaRepository.episodes
) { list, moviesMap, episodesMap ->
list.mapNotNull { media ->
when (media) {
is Media.MovieMedia -> {
val movie = mediaRepository.getMovie(media.movieId)
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = movie
)
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
ContinueWatchingItem(type = BaseItemKind.MOVIE, movie = it)
}
is Media.EpisodeMedia -> {
val episode = mediaRepository.getEpisode(
seriesId = media.seriesId,
episodeId = media.episodeId
)
ContinueWatchingItem(
type = BaseItemKind.EPISODE,
episode = episode
)
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
ContinueWatchingItem(type = BaseItemKind.EPISODE, episode = it)
}
else -> throw UnsupportedOperationException("Unsupported item type: $media")
else -> null
}
}.distinctBy { it.id }
}
}
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.stateIn(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
val latestLibraryContent = mediaRepository.latestLibraryContent
.mapLatest { libraryMap ->
withContext(Dispatchers.IO) {
val latestLibraryContent = combine(
mediaRepository.latestLibraryContent,
mediaRepository.movies,
mediaRepository.series,
mediaRepository.episodes
) { libraryMap, moviesMap, seriesMap, episodesMap ->
libraryMap.mapValues { (_, items) ->
items.map { media ->
items.mapNotNull { media ->
when (media) {
is Media.MovieMedia -> {
val movie = mediaRepository.getMovie(media.movieId)
PosterItem(
type = BaseItemKind.MOVIE,
movie = movie
)
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
PosterItem(type = BaseItemKind.MOVIE, movie = it)
}
is Media.EpisodeMedia -> {
val episode = mediaRepository.getEpisode(
seriesId = media.seriesId,
episodeId = media.episodeId
)
PosterItem(
type = BaseItemKind.EPISODE,
episode = episode
)
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
PosterItem(type = BaseItemKind.EPISODE, episode = it)
}
is Media.SeriesMedia -> {
val series = mediaRepository.getSeries(media.id)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
PosterItem(type = BaseItemKind.SERIES, series = it)
}
is Media.SeasonMedia -> {
val series = mediaRepository.getSeries(media.seriesId)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
is Media.SeasonMedia -> seriesMap[media.seriesId]?.let {
PosterItem(type = BaseItemKind.SERIES, series = it)
}
else -> throw UnsupportedOperationException("Unsupported item type: $media")
else -> null
}
}.distinctBy { it.id }
}
}
}
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.stateIn(
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyMap()

View File

@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.data.InMemoryMediaRepository
import hu.bbara.purefin.data.model.Media
import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.navigation.NavigationManager
@@ -14,7 +15,8 @@ import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
@@ -35,8 +37,26 @@ class LibraryViewModel @Inject constructor(
started = SharingStarted.Eagerly,
initialValue = ""
)
private val _contents = MutableStateFlow<List<PosterItem>>(emptyList())
val contents = _contents.asStateFlow()
private val _libraryItems = MutableStateFlow<List<Media>>(emptyList())
val contents: StateFlow<List<PosterItem>> = combine(
_libraryItems,
mediaRepository.movies,
mediaRepository.series
) { items, moviesMap, seriesMap ->
items.mapNotNull { media ->
when (media) {
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
PosterItem(type = BaseItemKind.MOVIE, movie = it)
}
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
PosterItem(type = BaseItemKind.SERIES, series = it)
}
else -> null
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
init {
viewModelScope.launch { mediaRepository.ensureReady() }
@@ -67,22 +87,10 @@ class LibraryViewModel @Inject constructor(
fun selectLibrary(libraryId: UUID) {
viewModelScope.launch {
val libraryItems = jellyfinApiClient.getLibraryContent(libraryId)
_contents.value = libraryItems.map {
_libraryItems.value = libraryItems.map {
when (it.type) {
BaseItemKind.MOVIE -> {
val movie = mediaRepository.getMovie(it.id)
PosterItem(
type = BaseItemKind.MOVIE,
movie = movie
)
}
BaseItemKind.SERIES -> {
val series = mediaRepository.getSeries(it.id)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
}
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = it.id)
BaseItemKind.SERIES -> Media.SeriesMedia(seriesId = it.id)
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
}
}

View File

@@ -53,8 +53,16 @@ class InMemoryMediaRepository @Inject constructor(
override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
localDataSource.observeSeriesWithContent(seriesId)
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
scope.launch {
awaitReady()
ensureSeriesContentLoaded(seriesId)
}
return localDataSource.observeSeriesWithContent(seriesId)
}
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
@@ -203,23 +211,11 @@ class InMemoryMediaRepository @Inject constructor(
//TODO Load seasons and episodes, other types are already loaded at this point.
}
override suspend fun getMovie(movieId: UUID): Movie {
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
awaitReady()
return localDataSource.getMovie(movieId)
?: throw RuntimeException("Movie not found")
}
override suspend fun getSeries(seriesId: UUID): Series {
awaitReady()
return localDataSource.getSeriesBasic(seriesId)
?: throw RuntimeException("Series not found")
}
override suspend fun getSeriesWithContent(seriesId: UUID): Series {
awaitReady()
// Use cached content if available
// Skip if content is already cached in Room
localDataSource.getSeriesWithContent(seriesId)?.takeIf { it.seasons.isNotEmpty() }?.let {
return it
return
}
val series = this.series.value[seriesId] ?: throw RuntimeException("Series not found")
@@ -234,68 +230,6 @@ class InMemoryMediaRepository @Inject constructor(
val updatedSeries = series.copy(seasons = filledSeasons)
localDataSource.saveSeries(listOf(updatedSeries))
localDataSource.saveSeriesContent(updatedSeries)
return updatedSeries
}
override suspend fun getSeason(
seriesId: UUID,
seasonId: UUID,
): Season {
awaitReady()
localDataSource.getSeason(seriesId, seasonId)?.let { return it }
// Fallback: ensure series content is loaded, then retry
val series = getSeriesWithContent(seriesId)
return series.seasons.find { it.id == seasonId }?: throw RuntimeException("Season not found")
}
override suspend fun getSeasons(
seriesId: UUID,
): List<Season> {
awaitReady()
val seasons = localDataSource.getSeasons(seriesId)
if (seasons.isNotEmpty()) return seasons
val series = getSeriesWithContent(seriesId)
return series.seasons
}
override suspend fun getEpisode(
seriesId: UUID,
episodeId: UUID
) : Episode {
awaitReady()
localDataSource.getEpisodeById(episodeId)?.let { return it }
val series = getSeriesWithContent(seriesId)
return series.seasons.flatMap { it.episodes }.find { it.id == episodeId }?: throw RuntimeException("Episode not found")
}
override suspend fun getEpisode(
seriesId: UUID,
seasonId: UUID,
episodeId: UUID
): Episode {
awaitReady()
localDataSource.getEpisode(seriesId, seasonId, episodeId)?.let { return it }
val series = getSeriesWithContent(seriesId)
return series.seasons.find { it.id == seasonId }?.episodes?.find { it.id == episodeId } ?: throw RuntimeException("Episode not found")
}
override suspend fun getEpisodes(
seriesId: UUID,
seasonId: UUID
): List<Episode> {
awaitReady()
val episodes = localDataSource.getSeason(seriesId, seasonId)?.episodes
if (episodes != null && episodes.isNotEmpty()) return episodes
val series = getSeriesWithContent(seriesId)
return series.seasons.find { it.id == seasonId }?.episodes ?: throw RuntimeException("Season not found")
}
override suspend fun getEpisodes(seriesId: UUID): List<Episode> {
awaitReady()
val episodes = localDataSource.getEpisodesBySeries(seriesId)
if (episodes.isNotEmpty()) return episodes
val series = getSeriesWithContent(seriesId)
return series.seasons.flatMap { it.episodes }
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {

View File

@@ -2,7 +2,6 @@ package hu.bbara.purefin.data
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Movie
import hu.bbara.purefin.data.model.Season
import hu.bbara.purefin.data.model.Series
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@@ -12,22 +11,13 @@ interface MediaRepository {
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>>
val state: StateFlow<MediaRepositoryState>
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
suspend fun ensureReady()
suspend fun getMovie(movieId: UUID) : Movie
suspend fun getSeries(seriesId: UUID) : Series
suspend fun getSeriesWithContent(seriesId: UUID) : Series
suspend fun getSeasons(seriesId: UUID) : List<Season>
suspend fun getSeason(seriesId: UUID, seasonId: UUID) : Season
suspend fun getEpisodes(seriesId: UUID) : List<Episode>
suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List<Episode>
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode
suspend fun getEpisode(seriesId: UUID, episodeId: UUID) : Episode
suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
suspend fun refreshHomeData()
}

View File

@@ -36,6 +36,9 @@ class RoomMediaLocalDataSource @Inject constructor(
entities.associate { it.id to it.toDomain(seasons = emptyList(), cast = emptyList()) }
}
val episodesFlow: Flow<Map<UUID, Episode>> = episodeDao.observeAll()
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
// Full content Flow for series detail screen (scoped to one series)
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
seriesDao.observeWithContent(seriesId).map { relation ->

View File

@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.local.room.EpisodeEntity
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Dao
@@ -20,6 +21,9 @@ interface EpisodeDao {
@Query("SELECT * FROM episodes WHERE seasonId = :seasonId")
suspend fun getBySeasonId(seasonId: UUID): List<EpisodeEntity>
@Query("SELECT * FROM episodes")
fun observeAll(): Flow<List<EpisodeEntity>>
@Query("SELECT * FROM episodes WHERE id = :id")
suspend fun getById(id: UUID): EpisodeEntity?