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 @Singleton
class CompositeMediaRepository @Inject constructor( class CompositeMediaRepository @Inject constructor(
private val offlineRepository: OfflineMediaRepository, private val offlineRepository: OfflineMediaRepository,
private val onlineRepository: AppContentRepository, private val onlineRepository: InMemoryMediaRepository,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
) : MediaRepository { ) : 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.Movie
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
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.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.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -42,10 +39,10 @@ import javax.inject.Singleton
class InMemoryAppContentRepository @Inject constructor( class InMemoryAppContentRepository @Inject constructor(
val userSessionRepository: UserSessionRepository, val userSessionRepository: UserSessionRepository,
val jellyfinApiClient: JellyfinApiClient, val jellyfinApiClient: JellyfinApiClient,
private val homeCacheDataStore: DataStore<HomeCache> private val homeCacheDataStore: DataStore<HomeCache>,
) : AppContentRepository { private val mediaRepository: InMemoryMediaRepository,
) : AppContentRepository, MediaRepository by mediaRepository {
private val ready = CompletableDeferred<Unit>()
private val readyMutex = Mutex() private val readyMutex = Mutex()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var initialLoadTimestamp = 0L private var initialLoadTimestamp = 0L
@@ -56,23 +53,6 @@ class InMemoryAppContentRepository @Inject constructor(
private val _libraries: MutableStateFlow<List<Library>> = MutableStateFlow(emptyList()) private val _libraries: MutableStateFlow<List<Library>> = MutableStateFlow(emptyList())
override val libraries: StateFlow<List<Library>> = _libraries.asStateFlow() 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()) private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow() override val continueWatching: StateFlow<List<Media>> = _continueWatching.asStateFlow()
@@ -136,8 +116,9 @@ class InMemoryAppContentRepository @Inject constructor(
} }
override suspend fun ensureReady() { override suspend fun ensureReady() {
val ready = mediaRepository.ready
if (ready.isCompleted) { if (ready.isCompleted) {
ready.await() // rethrows if completed exceptionally ready.await()
return return
} }
@@ -155,10 +136,10 @@ class InMemoryAppContentRepository @Inject constructor(
persistHomeCache() persistHomeCache()
_state.value = MediaRepositoryState.Ready _state.value = MediaRepositoryState.Ready
initialLoadTimestamp = System.currentTimeMillis() initialLoadTimestamp = System.currentTimeMillis()
ready.complete(Unit) mediaRepository.signalReady()
} catch (t: Throwable) { } catch (t: Throwable) {
_state.value = MediaRepositoryState.Error(t) _state.value = MediaRepositoryState.Error(t)
ready.completeExceptionally(t) mediaRepository.signalError(t)
throw t throw t
} finally { } finally {
readyMutex.unlock() readyMutex.unlock()
@@ -168,10 +149,6 @@ class InMemoryAppContentRepository @Inject constructor(
} }
} }
private suspend fun awaitReady() {
ready.await()
}
suspend fun loadLibraries() { suspend fun loadLibraries() {
val librariesItem = jellyfinApiClient.getLibraries() val librariesItem = jellyfinApiClient.getLibraries()
//TODO add support for playlists //TODO add support for playlists
@@ -186,10 +163,10 @@ class InMemoryAppContentRepository @Inject constructor(
_libraries.value = filledLibraries _libraries.value = filledLibraries
val movies = filledLibraries.filter { it.type == CollectionType.MOVIES }.flatMap { it.movies.orEmpty() } 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() } 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 { suspend fun loadLibrary(library: Library): Library {
@@ -207,19 +184,19 @@ class InMemoryAppContentRepository @Inject constructor(
} }
} }
suspend fun loadMovie(movie: Movie) : Movie { suspend fun loadMovie(movie: Movie): Movie {
val movieItem = jellyfinApiClient.getItemInfo(movie.id) val movieItem = jellyfinApiClient.getItemInfo(movie.id)
?: throw RuntimeException("Movie not found") ?: throw RuntimeException("Movie not found")
val updatedMovie = movieItem.toMovie(serverUrl(), movie.libraryId) val updatedMovie = movieItem.toMovie(serverUrl(), movie.libraryId)
_movies.update { it + (updatedMovie.id to updatedMovie) } mediaRepository._movies.update { it + (updatedMovie.id to updatedMovie) }
return updatedMovie return updatedMovie
} }
suspend fun loadSeries(series: Series) : Series { suspend fun loadSeries(series: Series): Series {
val seriesItem = jellyfinApiClient.getItemInfo(series.id) val seriesItem = jellyfinApiClient.getItemInfo(series.id)
?: throw RuntimeException("Series not found") ?: throw RuntimeException("Series not found")
val updatedSeries = seriesItem.toSeries(serverUrl(), series.libraryId) val updatedSeries = seriesItem.toSeries(serverUrl(), series.libraryId)
_series.update { it + (updatedSeries.id to updatedSeries) } mediaRepository._series.update { it + (updatedSeries.id to updatedSeries) }
return updatedSeries return updatedSeries
} }
@@ -242,7 +219,7 @@ class InMemoryAppContentRepository @Inject constructor(
when (item.type) { when (item.type) {
BaseItemKind.EPISODE -> { BaseItemKind.EPISODE -> {
val episode = item.toEpisode(serverUrl()) val episode = item.toEpisode(serverUrl())
_episodes.update { it + (episode.id to episode) } mediaRepository._episodes.update { it + (episode.id to episode) }
} }
else -> { /* Do nothing */ } else -> { /* Do nothing */ }
} }
@@ -262,7 +239,7 @@ class InMemoryAppContentRepository @Inject constructor(
// Load episodes // Load episodes
nextUpItems.forEach { item -> nextUpItems.forEach { item ->
val episode = item.toEpisode(serverUrl()) 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. //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 { companion object {
private const val REFRESH_MIN_INTERVAL_MS = 30_000L private const val REFRESH_MIN_INTERVAL_MS = 30_000L
} }
override suspend fun refreshHomeData() { override suspend fun refreshHomeData() {
awaitReady() mediaRepository.ready.await()
// Skip refresh if the initial load (or last refresh) just happened // Skip refresh if the initial load (or last refresh) just happened
val elapsed = System.currentTimeMillis() - initialLoadTimestamp val elapsed = System.currentTimeMillis() - initialLoadTimestamp
if (elapsed < REFRESH_MIN_INTERVAL_MS) return if (elapsed < REFRESH_MIN_INTERVAL_MS) return
@@ -397,7 +333,7 @@ class InMemoryAppContentRepository @Inject constructor(
} }
} }
private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID) : Movie { private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID): Movie {
return Movie( return Movie(
id = this.id, id = this.id,
libraryId = libraryId, libraryId = libraryId,
@@ -455,10 +391,6 @@ class InMemoryAppContentRepository @Inject constructor(
private fun BaseItemDto.toEpisode(serverUrl: String): Episode { private fun BaseItemDto.toEpisode(serverUrl: String): Episode {
val releaseDate = formatReleaseDate(premiereDate, productionYear) 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 -> val heroImageUrl = id?.let { itemId ->
JellyfinImageHelper.toImageUrl( JellyfinImageHelper.toImageUrl(
url = serverUrl, url = serverUrl,
@@ -473,12 +405,12 @@ class InMemoryAppContentRepository @Inject constructor(
title = name ?: "Unknown title", title = name ?: "Unknown title",
index = indexNumber!!, index = indexNumber!!,
releaseDate = releaseDate, releaseDate = releaseDate,
rating = rating, rating = officialRating ?: "NR",
runtime = runtime, runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage, progress = userData!!.playedPercentage,
watched = userData!!.played, watched = userData!!.played,
format = format, format = container?.uppercase() ?: "VIDEO",
synopsis = synopsis, synopsis = overview ?: "No synopsis available.",
heroImageUrl = heroImageUrl, heroImageUrl = heroImageUrl,
cast = emptyList() 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"
}
}