feat: enhance series data handling with reactive flows and content observation

This commit is contained in:
2026-02-07 16:48:56 +01:00
parent e3b13f2ea7
commit f932507469
8 changed files with 107 additions and 79 deletions

View File

@@ -8,8 +8,13 @@ import hu.bbara.purefin.data.model.Series
import hu.bbara.purefin.navigation.EpisodeDto
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import javax.inject.Inject
@@ -20,8 +25,14 @@ class SeriesViewModel @Inject constructor(
private val navigationManager: NavigationManager,
) : ViewModel() {
private val _series = MutableStateFlow<Series?>(null)
val series = _series.asStateFlow()
private val _seriesId = MutableStateFlow<UUID?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
val series: StateFlow<Series?> = _seriesId
.flatMapLatest { id ->
if (id != null) mediaRepository.observeSeriesWithContent(id) else flowOf(null)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
init {
viewModelScope.launch { mediaRepository.ensureReady() }
@@ -43,17 +54,15 @@ class SeriesViewModel @Inject constructor(
navigationManager.pop()
}
fun onGoHome() {
navigationManager.replaceAll(Route.Home)
}
fun selectSeries(seriesId: UUID) {
_seriesId.value = seriesId
// Ensure content is loaded from API if not cached
viewModelScope.launch {
val series = mediaRepository.getSeriesWithContent(
seriesId = seriesId
)
_series.value = series
mediaRepository.getSeriesWithContent(seriesId)
}
}
}

View File

@@ -14,10 +14,13 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
@@ -39,15 +42,19 @@ class InMemoryMediaRepository @Inject constructor(
) : MediaRepository {
private val ready = CompletableDeferred<Unit>()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Loading)
override val state: StateFlow<MediaRepositoryState> = _state.asStateFlow()
private val _movies : MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = _movies.asStateFlow()
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
private val _series : MutableStateFlow<Map<UUID, Series>> = MutableStateFlow(emptyMap())
override val series: StateFlow<Map<UUID, Series>> = _series.asStateFlow()
override val series: StateFlow<Map<UUID, Series>> = localDataSource.seriesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
localDataSource.observeSeriesWithContent(seriesId)
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
@@ -55,8 +62,6 @@ class InMemoryMediaRepository @Inject constructor(
private val _latestLibraryContent: MutableStateFlow<Map<UUID, List<Media>>> = MutableStateFlow(emptyMap())
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = _latestLibraryContent.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
scope.launch {
runCatching { ensureReady() }
@@ -96,11 +101,9 @@ class InMemoryMediaRepository @Inject constructor(
}
val movies = filledLibraries.filter { it.type == CollectionType.MOVIES }.flatMap { it.movies.orEmpty() }
localDataSource.saveMovies(movies)
_movies.value = localDataSource.getMovies().associateBy { it.id }
val series = filledLibraries.filter { it.type == CollectionType.TVSHOWS }.flatMap { it.series.orEmpty() }
localDataSource.saveSeries(series)
_series.value = localDataSource.getSeries().associateBy { it.id }
}
suspend fun loadLibrary(library: Library): Library {
@@ -123,7 +126,6 @@ class InMemoryMediaRepository @Inject constructor(
?: throw RuntimeException("Movie not found")
val updatedMovie = movieItem.toMovie(serverUrl(), movie.libraryId)
localDataSource.saveMovies(listOf(updatedMovie))
_movies.value += (movie.id to updatedMovie)
return updatedMovie
}
@@ -132,7 +134,6 @@ class InMemoryMediaRepository @Inject constructor(
?: throw RuntimeException("Series not found")
val updatedSeries = seriesItem.toSeries(serverUrl(), series.libraryId)
localDataSource.saveSeries(listOf(updatedSeries))
_series.value += (series.id to updatedSeries)
return updatedSeries
}
@@ -204,31 +205,24 @@ class InMemoryMediaRepository @Inject constructor(
override suspend fun getMovie(movieId: UUID): Movie {
awaitReady()
localDataSource.getMovie(movieId)?.let {
_movies.value += (movieId to it)
return it
}
throw RuntimeException("Movie not found")
return localDataSource.getMovie(movieId)
?: throw RuntimeException("Movie not found")
}
override suspend fun getSeries(seriesId: UUID): Series {
awaitReady()
localDataSource.getSeriesBasic(seriesId)?.let {
_series.value += (seriesId to it)
return it
}
throw RuntimeException("Series not found")
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 {
_series.value += (seriesId to it)
return it
}
val series = _series.value[seriesId] ?: throw RuntimeException("Series not found")
val series = this.series.value[seriesId] ?: throw RuntimeException("Series not found")
val emptySeasonsItem = jellyfinApiClient.getSeasons(seriesId)
val emptySeasons = emptySeasonsItem.map { it.toSeason(serverUrl()) }
@@ -240,7 +234,6 @@ class InMemoryMediaRepository @Inject constructor(
val updatedSeries = series.copy(seasons = filledSeasons)
localDataSource.saveSeries(listOf(updatedSeries))
localDataSource.saveSeriesContent(updatedSeries)
_series.value += (series.id to updatedSeries)
return updatedSeries
}
@@ -309,30 +302,8 @@ class InMemoryMediaRepository @Inject constructor(
if (durationMs <= 0) return
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0
val result = localDataSource.updateWatchProgress(mediaId, progressPercent, watched) ?: return
if (result.isMovie) {
_movies.value[mediaId]?.let { movie ->
_movies.value += (mediaId to movie.copy(progress = progressPercent, watched = watched))
}
} else {
val seriesId = result.seriesId ?: return
_series.value[seriesId]?.let { currentSeries ->
val updatedSeasons = currentSeries.seasons.map { season ->
if (season.id == result.seasonId) {
val updatedEpisodes = season.episodes.map { ep ->
if (ep.id == mediaId) ep.copy(progress = progressPercent, watched = watched) else ep
}
season.copy(unwatchedEpisodeCount = result.seasonUnwatchedCount, episodes = updatedEpisodes)
} else season
}
_series.value += (seriesId to currentSeries.copy(
unwatchedEpisodeCount = result.seriesUnwatchedCount,
seasons = updatedSeasons
))
}
}
// Write to Room — the reactive Flows propagate changes to UI automatically
localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
}
override suspend fun refreshHomeData() {

View File

@@ -4,6 +4,7 @@ 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
import java.util.UUID
@@ -13,6 +14,8 @@ interface MediaRepository {
val series: StateFlow<Map<UUID, Series>>
val state: StateFlow<MediaRepositoryState>
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?>
suspend fun ensureReady()
suspend fun getMovie(movieId: UUID) : Movie

View File

@@ -21,7 +21,7 @@ import java.util.UUID
data class EpisodeEntity(
@PrimaryKey val id: UUID,
val seriesId: UUID,
val seasonId: UUID?,
val seasonId: UUID,
val index: Int,
val title: String,
val synopsis: String,

View File

@@ -11,6 +11,8 @@ 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.map
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -25,6 +27,30 @@ class RoomMediaLocalDataSource @Inject constructor(
private val castMemberDao: CastMemberDao
) {
// Lightweight Flows for list screens (home, library)
val moviesFlow: Flow<Map<UUID, Movie>> = movieDao.observeAll()
.map { entities -> entities.associate { it.id to it.toDomain(cast = emptyList()) } }
val seriesFlow: Flow<Map<UUID, Series>> = seriesDao.observeAll()
.map { entities ->
entities.associate { it.id to it.toDomain(seasons = emptyList(), cast = emptyList()) }
}
// Full content Flow for series detail screen (scoped to one series)
fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> =
seriesDao.observeWithContent(seriesId).map { relation ->
relation?.let {
it.series.toDomain(
seasons = it.seasons.map { swe ->
swe.season.toDomain(
episodes = swe.episodes.map { ep -> ep.toDomain(cast = emptyList()) }
)
},
cast = emptyList()
)
}
}
suspend fun saveMovies(movies: List<Movie>) {
database.withTransaction {
movieDao.upsertAll(movies.map { it.toEntity() })
@@ -131,39 +157,21 @@ class RoomMediaLocalDataSource @Inject constructor(
return episodeEntity.toDomain(cast)
}
data class WatchProgressResult(
val isMovie: Boolean,
val seriesId: UUID? = null,
val seasonId: UUID? = null,
val seriesUnwatchedCount: Int = 0,
val seasonUnwatchedCount: Int = 0
)
suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean): WatchProgressResult? {
suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean) {
movieDao.getById(mediaId)?.let {
movieDao.updateProgress(mediaId, progress, watched)
return WatchProgressResult(isMovie = true)
return
}
episodeDao.getById(mediaId)?.let { episode ->
return database.withTransaction {
database.withTransaction {
episodeDao.updateProgress(mediaId, progress, watched)
val seasonUnwatched = episodeDao.countUnwatchedBySeason(episode.seasonId!!)
val seasonUnwatched = episodeDao.countUnwatchedBySeason(episode.seasonId)
seasonDao.updateUnwatchedCount(episode.seasonId, seasonUnwatched)
val seriesUnwatched = episodeDao.countUnwatchedBySeries(episode.seriesId)
seriesDao.updateUnwatchedCount(episode.seriesId, seriesUnwatched)
WatchProgressResult(
isMovie = false,
seriesId = episode.seriesId,
seasonId = episode.seasonId,
seriesUnwatchedCount = seriesUnwatched,
seasonUnwatchedCount = seasonUnwatched
)
}
}
return null
}
suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
@@ -268,7 +276,7 @@ class RoomMediaLocalDataSource @Inject constructor(
private fun EpisodeEntity.toDomain(cast: List<CastMember>) = Episode(
id = id,
seriesId = seriesId,
seasonId = seasonId ?: seriesId, // fallback to series when season is absent
seasonId = seasonId,
index = index,
title = title,
synopsis = synopsis,

View File

@@ -0,0 +1,23 @@
package hu.bbara.purefin.data.local.room
import androidx.room.Embedded
import androidx.room.Relation
data class SeasonWithEpisodes(
@Embedded val season: SeasonEntity,
@Relation(
parentColumn = "id",
entityColumn = "seasonId"
)
val episodes: List<EpisodeEntity>
)
data class SeriesWithSeasonsAndEpisodes(
@Embedded val series: SeriesEntity,
@Relation(
entity = SeasonEntity::class,
parentColumn = "id",
entityColumn = "seriesId"
)
val seasons: List<SeasonWithEpisodes>
)

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.MovieEntity
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Dao
@@ -17,6 +18,9 @@ interface MovieDao {
@Query("SELECT * FROM movies")
suspend fun getAll(): List<MovieEntity>
@Query("SELECT * FROM movies")
fun observeAll(): Flow<List<MovieEntity>>
@Query("SELECT * FROM movies WHERE id = :id")
suspend fun getById(id: UUID): MovieEntity?

View File

@@ -2,8 +2,11 @@ package hu.bbara.purefin.data.local.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import hu.bbara.purefin.data.local.room.SeriesEntity
import hu.bbara.purefin.data.local.room.SeriesWithSeasonsAndEpisodes
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Dao
@@ -17,6 +20,13 @@ interface SeriesDao {
@Query("SELECT * FROM series")
suspend fun getAll(): List<SeriesEntity>
@Query("SELECT * FROM series")
fun observeAll(): Flow<List<SeriesEntity>>
@Transaction
@Query("SELECT * FROM series WHERE id = :id")
fun observeWithContent(id: UUID): Flow<SeriesWithSeasonsAndEpisodes?>
@Query("SELECT * FROM series WHERE id = :id")
suspend fun getById(id: UUID): SeriesEntity?