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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.data.InMemoryMediaRepository import hu.bbara.purefin.data.InMemoryMediaRepository
import hu.bbara.purefin.data.model.Media
import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.navigation.MovieDto import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.NavigationManager
@@ -14,7 +15,8 @@ import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.session.UserSessionRepository import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted 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.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
@@ -35,8 +37,26 @@ class LibraryViewModel @Inject constructor(
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = "" 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 { init {
viewModelScope.launch { mediaRepository.ensureReady() } viewModelScope.launch { mediaRepository.ensureReady() }
@@ -67,22 +87,10 @@ class LibraryViewModel @Inject constructor(
fun selectLibrary(libraryId: UUID) { fun selectLibrary(libraryId: UUID) {
viewModelScope.launch { viewModelScope.launch {
val libraryItems = jellyfinApiClient.getLibraryContent(libraryId) val libraryItems = jellyfinApiClient.getLibraryContent(libraryId)
_contents.value = libraryItems.map { _libraryItems.value = libraryItems.map {
when (it.type) { when (it.type) {
BaseItemKind.MOVIE -> { BaseItemKind.MOVIE -> Media.MovieMedia(movieId = it.id)
val movie = mediaRepository.getMovie(it.id) BaseItemKind.SERIES -> Media.SeriesMedia(seriesId = it.id)
PosterItem(
type = BaseItemKind.MOVIE,
movie = movie
)
}
BaseItemKind.SERIES -> {
val series = mediaRepository.getSeries(it.id)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
}
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}") 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 override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap()) .stateIn(scope, SharingStarted.Eagerly, emptyMap())
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> = override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
localDataSource.observeSeriesWithContent(seriesId) .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()) private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow() 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. //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() awaitReady()
return localDataSource.getMovie(movieId) // Skip if content is already cached in Room
?: 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
localDataSource.getSeriesWithContent(seriesId)?.takeIf { it.seasons.isNotEmpty() }?.let { localDataSource.getSeriesWithContent(seriesId)?.takeIf { it.seasons.isNotEmpty() }?.let {
return it return
} }
val series = this.series.value[seriesId] ?: throw RuntimeException("Series not found") 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) val updatedSeries = series.copy(seasons = filledSeasons)
localDataSource.saveSeries(listOf(updatedSeries)) localDataSource.saveSeries(listOf(updatedSeries))
localDataSource.saveSeriesContent(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) { 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.Episode
import hu.bbara.purefin.data.model.Movie import hu.bbara.purefin.data.model.Movie
import hu.bbara.purefin.data.model.Season
import hu.bbara.purefin.data.model.Series import hu.bbara.purefin.data.model.Series
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -12,22 +11,13 @@ interface MediaRepository {
val movies: StateFlow<Map<UUID, Movie>> val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>> val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>>
val state: StateFlow<MediaRepositoryState> val state: StateFlow<MediaRepositoryState>
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
suspend fun ensureReady() 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 updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long)
suspend fun refreshHomeData() suspend fun refreshHomeData()
} }

View File

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

View File

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