mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
feat: enhance series data handling with reactive flows and content observation
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user