refactor: extract InMemoryMediaRepository from InMemoryAppContentRepository

- Create InMemoryMediaRepository to hold in-memory media state (movies,
  series, episodes), lazy series content loading, and watch progress updates
- InMemoryAppContentRepository delegates MediaRepository via Kotlin's by
  delegation, injecting InMemoryMediaRepository as a composite component
- CompositeMediaRepository now uses InMemoryMediaRepository directly as the
  online repository instead of AppContentRepository
This commit is contained in:
2026-02-22 14:37:41 +01:00
parent 69fda1fef2
commit 4ecb402e69
3 changed files with 182 additions and 90 deletions

View File

@@ -27,7 +27,7 @@ import javax.inject.Singleton
@Singleton
class CompositeMediaRepository @Inject constructor(
private val offlineRepository: OfflineMediaRepository,
private val onlineRepository: AppContentRepository,
private val onlineRepository: InMemoryMediaRepository,
private val userSessionRepository: UserSessionRepository,
) : MediaRepository {

View File

@@ -13,16 +13,13 @@ import hu.bbara.purefin.core.model.MediaRepositoryState
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -42,10 +39,10 @@ import javax.inject.Singleton
class InMemoryAppContentRepository @Inject constructor(
val userSessionRepository: UserSessionRepository,
val jellyfinApiClient: JellyfinApiClient,
private val homeCacheDataStore: DataStore<HomeCache>
) : AppContentRepository {
private val homeCacheDataStore: DataStore<HomeCache>,
private val mediaRepository: InMemoryMediaRepository,
) : AppContentRepository, MediaRepository by mediaRepository {
private val ready = CompletableDeferred<Unit>()
private val readyMutex = Mutex()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var initialLoadTimestamp = 0L
@@ -56,23 +53,6 @@ class InMemoryAppContentRepository @Inject constructor(
private val _libraries: MutableStateFlow<List<Library>> = MutableStateFlow(emptyList())
override val libraries: StateFlow<List<Library>> = _libraries.asStateFlow()
private val _movies: MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = _movies.asStateFlow()
private val _series: MutableStateFlow<Map<UUID, Series>> = MutableStateFlow(emptyMap())
override val series: StateFlow<Map<UUID, Series>> = _series.asStateFlow()
private val _episodes: MutableStateFlow<Map<UUID, Episode>> = MutableStateFlow(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = _episodes.asStateFlow()
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
scope.launch {
awaitReady()
ensureSeriesContentLoaded(seriesId)
}
return _series.map { it[seriesId] }
}
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
@@ -136,8 +116,9 @@ class InMemoryAppContentRepository @Inject constructor(
}
override suspend fun ensureReady() {
val ready = mediaRepository.ready
if (ready.isCompleted) {
ready.await() // rethrows if completed exceptionally
ready.await()
return
}
@@ -155,10 +136,10 @@ class InMemoryAppContentRepository @Inject constructor(
persistHomeCache()
_state.value = MediaRepositoryState.Ready
initialLoadTimestamp = System.currentTimeMillis()
ready.complete(Unit)
mediaRepository.signalReady()
} catch (t: Throwable) {
_state.value = MediaRepositoryState.Error(t)
ready.completeExceptionally(t)
mediaRepository.signalError(t)
throw t
} finally {
readyMutex.unlock()
@@ -168,10 +149,6 @@ class InMemoryAppContentRepository @Inject constructor(
}
}
private suspend fun awaitReady() {
ready.await()
}
suspend fun loadLibraries() {
val librariesItem = jellyfinApiClient.getLibraries()
//TODO add support for playlists
@@ -186,10 +163,10 @@ class InMemoryAppContentRepository @Inject constructor(
_libraries.value = filledLibraries
val movies = filledLibraries.filter { it.type == CollectionType.MOVIES }.flatMap { it.movies.orEmpty() }
_movies.update { current -> current + movies.associateBy { it.id } }
mediaRepository._movies.update { current -> current + movies.associateBy { it.id } }
val series = filledLibraries.filter { it.type == CollectionType.TVSHOWS }.flatMap { it.series.orEmpty() }
_series.update { current -> current + series.associateBy { it.id } }
mediaRepository._series.update { current -> current + series.associateBy { it.id } }
}
suspend fun loadLibrary(library: Library): Library {
@@ -211,7 +188,7 @@ class InMemoryAppContentRepository @Inject constructor(
val movieItem = jellyfinApiClient.getItemInfo(movie.id)
?: throw RuntimeException("Movie not found")
val updatedMovie = movieItem.toMovie(serverUrl(), movie.libraryId)
_movies.update { it + (updatedMovie.id to updatedMovie) }
mediaRepository._movies.update { it + (updatedMovie.id to updatedMovie) }
return updatedMovie
}
@@ -219,7 +196,7 @@ class InMemoryAppContentRepository @Inject constructor(
val seriesItem = jellyfinApiClient.getItemInfo(series.id)
?: throw RuntimeException("Series not found")
val updatedSeries = seriesItem.toSeries(serverUrl(), series.libraryId)
_series.update { it + (updatedSeries.id to updatedSeries) }
mediaRepository._series.update { it + (updatedSeries.id to updatedSeries) }
return updatedSeries
}
@@ -242,7 +219,7 @@ class InMemoryAppContentRepository @Inject constructor(
when (item.type) {
BaseItemKind.EPISODE -> {
val episode = item.toEpisode(serverUrl())
_episodes.update { it + (episode.id to episode) }
mediaRepository._episodes.update { it + (episode.id to episode) }
}
else -> { /* Do nothing */ }
}
@@ -262,7 +239,7 @@ class InMemoryAppContentRepository @Inject constructor(
// Load episodes
nextUpItems.forEach { item ->
val episode = item.toEpisode(serverUrl())
_episodes.update { it + (episode.id to episode) }
mediaRepository._episodes.update { it + (episode.id to episode) }
}
}
@@ -306,53 +283,12 @@ class InMemoryAppContentRepository @Inject constructor(
//TODO Load seasons and episodes, other types are already loaded at this point.
}
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
awaitReady()
// Skip if content is already loaded in-memory
_series.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
val series = this.series.value[seriesId] ?: throw RuntimeException("Series not found")
val emptySeasonsItem = jellyfinApiClient.getSeasons(seriesId)
val emptySeasons = emptySeasonsItem.map { it.toSeason(serverUrl()) }
val filledSeasons = emptySeasons.map { season ->
val episodesItem = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
val episodes = episodesItem.map { it.toEpisode(serverUrl()) }
season.copy(episodes = episodes)
}
val updatedSeries = series.copy(seasons = filledSeasons)
_series.update { it + (updatedSeries.id to updatedSeries) }
val allEpisodes = filledSeasons.flatMap { it.episodes }
_episodes.update { current -> current + allEpisodes.associateBy { it.id } }
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
if (durationMs <= 0) return
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0
if (_movies.value.containsKey(mediaId)) {
_movies.update { current ->
val movie = current[mediaId] ?: return@update current
current + (mediaId to movie.copy(progress = progressPercent, watched = watched))
}
return
}
if (_episodes.value.containsKey(mediaId)) {
_episodes.update { current ->
val episode = current[mediaId] ?: return@update current
current + (mediaId to episode.copy(progress = progressPercent, watched = watched))
}
}
}
companion object {
private const val REFRESH_MIN_INTERVAL_MS = 30_000L
}
override suspend fun refreshHomeData() {
awaitReady()
mediaRepository.ready.await()
// Skip refresh if the initial load (or last refresh) just happened
val elapsed = System.currentTimeMillis() - initialLoadTimestamp
if (elapsed < REFRESH_MIN_INTERVAL_MS) return
@@ -455,10 +391,6 @@ class InMemoryAppContentRepository @Inject constructor(
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear)
val rating = officialRating ?: "NR"
val runtime = formatRuntime(runTimeTicks)
val format = container?.uppercase() ?: "VIDEO"
val synopsis = overview ?: "No synopsis available."
val heroImageUrl = id?.let { itemId ->
JellyfinImageHelper.toImageUrl(
url = serverUrl,
@@ -473,12 +405,12 @@ class InMemoryAppContentRepository @Inject constructor(
title = name ?: "Unknown title",
index = indexNumber!!,
releaseDate = releaseDate,
rating = rating,
runtime = runtime,
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage,
watched = userData!!.played,
format = format,
synopsis = synopsis,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
heroImageUrl = heroImageUrl,
cast = emptyList()
)

View File

@@ -0,0 +1,160 @@
package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class InMemoryMediaRepository @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient,
) : MediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
internal val ready = CompletableDeferred<Unit>()
internal val _movies: MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = _movies.asStateFlow()
internal val _series: MutableStateFlow<Map<UUID, Series>> = MutableStateFlow(emptyMap())
override val series: StateFlow<Map<UUID, Series>> = _series.asStateFlow()
internal val _episodes: MutableStateFlow<Map<UUID, Episode>> = MutableStateFlow(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = _episodes.asStateFlow()
internal fun signalReady() {
ready.complete(Unit)
}
internal fun signalError(t: Throwable) {
ready.completeExceptionally(t)
}
private suspend fun awaitReady() {
ready.await()
}
override fun observeSeriesWithContent(seriesId: UUID): Flow<Series?> {
scope.launch {
awaitReady()
ensureSeriesContentLoaded(seriesId)
}
return _series.map { it[seriesId] }
}
private suspend fun ensureSeriesContentLoaded(seriesId: UUID) {
_series.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
val series = _series.value[seriesId] ?: throw RuntimeException("Series not found")
val serverUrl = serverUrl()
val emptySeasonsItem = jellyfinApiClient.getSeasons(seriesId)
val emptySeasons = emptySeasonsItem.map { it.toSeason(serverUrl) }
val filledSeasons = emptySeasons.map { season ->
val episodesItem = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
val episodes = episodesItem.map { it.toEpisode(serverUrl) }
season.copy(episodes = episodes)
}
val updatedSeries = series.copy(seasons = filledSeasons)
_series.update { it + (updatedSeries.id to updatedSeries) }
val allEpisodes = filledSeasons.flatMap { it.episodes }
_episodes.update { current -> current + allEpisodes.associateBy { it.id } }
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
if (durationMs <= 0) return
val progressPercent = (positionMs.toDouble() / durationMs.toDouble()) * 100.0
val watched = progressPercent >= 90.0
if (_movies.value.containsKey(mediaId)) {
_movies.update { current ->
val movie = current[mediaId] ?: return@update current
current + (mediaId to movie.copy(progress = progressPercent, watched = watched))
}
return
}
if (_episodes.value.containsKey(mediaId)) {
_episodes.update { current ->
val episode = current[mediaId] ?: return@update current
current + (mediaId to episode.copy(progress = progressPercent, watched = watched))
}
}
}
private suspend fun serverUrl(): String = userSessionRepository.serverUrl.first()
private fun BaseItemDto.toSeason(serverUrl: String): Season {
return Season(
id = this.id,
seriesId = this.seriesId!!,
name = this.name ?: "Unknown",
index = this.indexNumber ?: 0,
unwatchedEpisodeCount = this.userData!!.unplayedItemCount!!,
episodeCount = this.childCount!!,
episodes = emptyList()
)
}
private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear)
val heroImageUrl = id?.let { itemId ->
JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = itemId, type = ImageType.PRIMARY)
} ?: ""
return Episode(
id = id,
seriesId = seriesId!!,
seasonId = parentId!!,
title = name ?: "Unknown title",
index = indexNumber!!,
releaseDate = releaseDate,
rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage,
watched = userData!!.played,
format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.",
heroImageUrl = heroImageUrl,
cast = emptyList()
)
}
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
if (date == null) return fallbackYear?.toString() ?: ""
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
return date.toLocalDate().format(formatter)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
}