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.EpisodeDto
import hu.bbara.purefin.navigation.NavigationManager import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.navigation.Route
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.flatMapLatest
import kotlinx.coroutines.flow.flowOf
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
@@ -20,8 +25,14 @@ class SeriesViewModel @Inject constructor(
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
private val _series = MutableStateFlow<Series?>(null) private val _seriesId = MutableStateFlow<UUID?>(null)
val series = _series.asStateFlow()
@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 { init {
viewModelScope.launch { mediaRepository.ensureReady() } viewModelScope.launch { mediaRepository.ensureReady() }
@@ -43,17 +54,15 @@ class SeriesViewModel @Inject constructor(
navigationManager.pop() navigationManager.pop()
} }
fun onGoHome() { fun onGoHome() {
navigationManager.replaceAll(Route.Home) navigationManager.replaceAll(Route.Home)
} }
fun selectSeries(seriesId: UUID) { fun selectSeries(seriesId: UUID) {
_seriesId.value = seriesId
// Ensure content is loaded from API if not cached
viewModelScope.launch { viewModelScope.launch {
val series = mediaRepository.getSeriesWithContent( mediaRepository.getSeriesWithContent(seriesId)
seriesId = seriesId
)
_series.value = series
} }
} }
} }

View File

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

View File

@@ -21,7 +21,7 @@ import java.util.UUID
data class EpisodeEntity( data class EpisodeEntity(
@PrimaryKey val id: UUID, @PrimaryKey val id: UUID,
val seriesId: UUID, val seriesId: UUID,
val seasonId: UUID?, val seasonId: UUID,
val index: Int, val index: Int,
val title: String, val title: String,
val synopsis: 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.Movie
import hu.bbara.purefin.data.model.Season 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.map
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -25,6 +27,30 @@ class RoomMediaLocalDataSource @Inject constructor(
private val castMemberDao: CastMemberDao 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>) { suspend fun saveMovies(movies: List<Movie>) {
database.withTransaction { database.withTransaction {
movieDao.upsertAll(movies.map { it.toEntity() }) movieDao.upsertAll(movies.map { it.toEntity() })
@@ -131,39 +157,21 @@ class RoomMediaLocalDataSource @Inject constructor(
return episodeEntity.toDomain(cast) return episodeEntity.toDomain(cast)
} }
suspend fun updateWatchProgress(mediaId: UUID, progress: Double?, watched: Boolean) {
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? {
movieDao.getById(mediaId)?.let { movieDao.getById(mediaId)?.let {
movieDao.updateProgress(mediaId, progress, watched) movieDao.updateProgress(mediaId, progress, watched)
return WatchProgressResult(isMovie = true) return
} }
episodeDao.getById(mediaId)?.let { episode -> episodeDao.getById(mediaId)?.let { episode ->
return database.withTransaction { database.withTransaction {
episodeDao.updateProgress(mediaId, progress, watched) episodeDao.updateProgress(mediaId, progress, watched)
val seasonUnwatched = episodeDao.countUnwatchedBySeason(episode.seasonId!!) val seasonUnwatched = episodeDao.countUnwatchedBySeason(episode.seasonId)
seasonDao.updateUnwatchedCount(episode.seasonId, seasonUnwatched) seasonDao.updateUnwatchedCount(episode.seasonId, seasonUnwatched)
val seriesUnwatched = episodeDao.countUnwatchedBySeries(episode.seriesId) val seriesUnwatched = episodeDao.countUnwatchedBySeries(episode.seriesId)
seriesDao.updateUnwatchedCount(episode.seriesId, seriesUnwatched) 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> { suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
@@ -268,7 +276,7 @@ class RoomMediaLocalDataSource @Inject constructor(
private fun EpisodeEntity.toDomain(cast: List<CastMember>) = Episode( private fun EpisodeEntity.toDomain(cast: List<CastMember>) = Episode(
id = id, id = id,
seriesId = seriesId, seriesId = seriesId,
seasonId = seasonId ?: seriesId, // fallback to series when season is absent seasonId = seasonId,
index = index, index = index,
title = title, title = title,
synopsis = synopsis, 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.Query
import androidx.room.Upsert import androidx.room.Upsert
import hu.bbara.purefin.data.local.room.MovieEntity import hu.bbara.purefin.data.local.room.MovieEntity
import kotlinx.coroutines.flow.Flow
import java.util.UUID import java.util.UUID
@Dao @Dao
@@ -17,6 +18,9 @@ interface MovieDao {
@Query("SELECT * FROM movies") @Query("SELECT * FROM movies")
suspend fun getAll(): List<MovieEntity> suspend fun getAll(): List<MovieEntity>
@Query("SELECT * FROM movies")
fun observeAll(): Flow<List<MovieEntity>>
@Query("SELECT * FROM movies WHERE id = :id") @Query("SELECT * FROM movies WHERE id = :id")
suspend fun getById(id: UUID): MovieEntity? 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.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import hu.bbara.purefin.data.local.room.SeriesEntity 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 import java.util.UUID
@Dao @Dao
@@ -17,6 +20,13 @@ interface SeriesDao {
@Query("SELECT * FROM series") @Query("SELECT * FROM series")
suspend fun getAll(): List<SeriesEntity> 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") @Query("SELECT * FROM series WHERE id = :id")
suspend fun getById(id: UUID): SeriesEntity? suspend fun getById(id: UUID): SeriesEntity?