From 7f83bb181b483ca74790f5e3d8c78f6cfb4bc2cd Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Tue, 21 Apr 2026 22:04:30 +0200 Subject: [PATCH] refactor: Include media data in HomeCache so the first startup is faster. --- .../catalog/InMemoryAppContentRepository.kt | 84 ++++-- .../purefin/data/offline/cache/HomeCache.kt | 249 +++++++++++++++++- 2 files changed, 315 insertions(+), 18 deletions(-) diff --git a/data/catalog/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt b/data/catalog/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt index b2a08ceb..8a3af514 100644 --- a/data/catalog/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt +++ b/data/catalog/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt @@ -15,8 +15,17 @@ import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient -import hu.bbara.purefin.data.offline.cache.CachedMediaItem import hu.bbara.purefin.data.offline.cache.HomeCache +import hu.bbara.purefin.data.offline.cache.toCachedEpisode +import hu.bbara.purefin.data.offline.cache.toCachedItem +import hu.bbara.purefin.data.offline.cache.toCachedLibrary +import hu.bbara.purefin.data.offline.cache.toCachedMovie +import hu.bbara.purefin.data.offline.cache.toCachedSeries +import hu.bbara.purefin.data.offline.cache.toEpisode +import hu.bbara.purefin.data.offline.cache.toLibrary +import hu.bbara.purefin.data.offline.cache.toMedia +import hu.bbara.purefin.data.offline.cache.toMovie +import hu.bbara.purefin.data.offline.cache.toSeries import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -112,6 +121,16 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadHomeCache() { Log.d(TAG, "Loading home cache") val cache = homeCacheDataStore.data.first() + if (cache.libraries.isNotEmpty()) { + val libraries = cache.libraries.mapNotNull { it.toLibrary() } + librariesState.value = libraries + onlineMediaRepository.upsertMovies( + libraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() } + ) + onlineMediaRepository.upsertSeries( + libraries.filter { it.type == LibraryKind.SERIES }.flatMap { it.series.orEmpty() } + ) + } if (cache.suggestions.isNotEmpty()) { suggestionsState.value = cache.suggestions.mapNotNull { it.toMedia() } } @@ -127,10 +146,23 @@ class InMemoryAppContentRepository @Inject constructor( uuid to items.mapNotNull { it.toMedia() } }.toMap() } + if (cache.movies.isNotEmpty()) { + onlineMediaRepository.upsertMovies(cache.movies.mapNotNull { it.toMovie() }) + } + if (cache.series.isNotEmpty()) { + onlineMediaRepository.upsertSeries(cache.series.mapNotNull { it.toSeries() }) + } + if (cache.episodes.isNotEmpty()) { + onlineMediaRepository.upsertEpisodes(cache.episodes.mapNotNull { it.toEpisode() }) + } Log.d(TAG, "Home cache loaded") } private suspend fun persistHomeCache() { + val referencedMediaIds = collectReferencedMediaIds() + val movies = onlineMediaRepository.movies.value + val series = onlineMediaRepository.series.value + val episodes = onlineMediaRepository.episodes.value val cache = HomeCache( suggestions = suggestionsState.value.map { it.toCachedItem() }, continueWatching = continueWatchingState.value.map { it.toCachedItem() }, @@ -138,27 +170,39 @@ class InMemoryAppContentRepository @Inject constructor( latestLibraryContent = latestLibraryContentState.value.map { (uuid, items) -> uuid.toString() to items.map { it.toCachedItem() } }.toMap(), + libraries = librariesState.value.map { it.toCachedLibrary() }, + movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() }, + series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() }, + episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() }, ) homeCacheDataStore.updateData { cache } } - private fun Media.toCachedItem(): CachedMediaItem = when (this) { - is Media.MovieMedia -> CachedMediaItem(type = "MOVIE", id = movieId.toString()) - is Media.SeriesMedia -> CachedMediaItem(type = "SERIES", id = seriesId.toString()) - is Media.SeasonMedia -> CachedMediaItem(type = "SEASON", id = seasonId.toString(), seriesId = seriesId.toString()) - is Media.EpisodeMedia -> CachedMediaItem(type = "EPISODE", id = episodeId.toString(), seriesId = seriesId.toString()) - } - - private fun CachedMediaItem.toMedia(): Media? { - val uuid = runCatching { UUID.fromString(id) }.getOrNull() ?: return null - val seriesUuid = seriesId?.let { runCatching { UUID.fromString(it) }.getOrNull() } - return when (type) { - "MOVIE" -> Media.MovieMedia(movieId = uuid) - "SERIES" -> Media.SeriesMedia(seriesId = uuid) - "SEASON" -> Media.SeasonMedia(seasonId = uuid, seriesId = seriesUuid ?: return null) - "EPISODE" -> Media.EpisodeMedia(episodeId = uuid, seriesId = seriesUuid ?: return null) - else -> null + private fun collectReferencedMediaIds(): ReferencedHomeMediaIds { + val movieIds = mutableSetOf() + val seriesIds = mutableSetOf() + val episodeIds = mutableSetOf() + val referencedMedia = buildList { + addAll(suggestionsState.value) + addAll(continueWatchingState.value) + addAll(nextUpState.value) + latestLibraryContentState.value.values.forEach { addAll(it) } } + + referencedMedia.forEach { media -> + when (media) { + is Media.MovieMedia -> movieIds += media.movieId + is Media.SeriesMedia -> seriesIds += media.seriesId + is Media.SeasonMedia -> seriesIds += media.seriesId + is Media.EpisodeMedia -> episodeIds += media.episodeId + } + } + + return ReferencedHomeMediaIds( + movieIds = movieIds, + seriesIds = seriesIds, + episodeIds = episodeIds, + ) } suspend fun loadLibraries() { @@ -412,3 +456,9 @@ class InMemoryAppContentRepository @Inject constructor( private const val TAG = "InMemoryAppContentRepo" } } + +private data class ReferencedHomeMediaIds( + val movieIds: Set, + val seriesIds: Set, + val episodeIds: Set, +) diff --git a/data/offline/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt b/data/offline/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt index e5aa9426..385caf10 100644 --- a/data/offline/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt +++ b/data/offline/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt @@ -1,6 +1,70 @@ package hu.bbara.purefin.data.offline.cache +import hu.bbara.purefin.core.model.CastMember +import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.core.model.Library +import hu.bbara.purefin.core.model.LibraryKind +import hu.bbara.purefin.core.model.Media +import hu.bbara.purefin.core.model.Movie +import hu.bbara.purefin.core.model.Series import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class CachedCastMember( + val name: String, + val role: String, + val imageUrl: String? = null +) + +@Serializable +data class CachedMovie( + val id: String, + val libraryId: String, + val title: String, + val progress: Double? = null, + val watched: Boolean, + val year: String, + val rating: String, + val runtime: String, + val format: String, + val synopsis: String, + val imageUrlPrefix: String, + val audioTrack: String, + val subtitles: String, + val cast: List = emptyList() +) + +@Serializable +data class CachedSeries( + val id: String, + val libraryId: String, + val name: String, + val synopsis: String, + val year: String, + val imageUrlPrefix: String, + val unwatchedEpisodeCount: Int, + val seasonCount: Int, + val cast: List = emptyList() +) + +@Serializable +data class CachedEpisode( + val id: String, + val seriesId: String, + val seasonId: String, + val index: Int, + val title: String, + val synopsis: String, + val releaseDate: String, + val rating: String, + val runtime: String, + val progress: Double? = null, + val watched: Boolean, + val format: String, + val imageUrlPrefix: String, + val cast: List = emptyList() +) @Serializable data class CachedMediaItem( @@ -9,10 +73,193 @@ data class CachedMediaItem( val seriesId: String? = null ) +@Serializable +data class CachedLibrary( + val id: String, + val name: String, + val type: String, + val posterUrl: String, + val series: List? = null, + val movies: List? = null, +) + @Serializable data class HomeCache( val suggestions: List = emptyList(), val continueWatching: List = emptyList(), val nextUp: List = emptyList(), - val latestLibraryContent: Map> = emptyMap() + val latestLibraryContent: Map> = emptyMap(), + val libraries: List = emptyList(), + val movies: List = emptyList(), + val series: List = emptyList(), + val episodes: List = emptyList() ) + +fun Library.toCachedLibrary() = CachedLibrary( + id = id.toString(), + name = name, + type = type.name, + posterUrl = posterUrl, + series = series?.map { it.toCachedSeries() }, + movies = movies?.map { it.toCachedMovie() }, +) + +fun CachedLibrary.toLibrary(): Library? { + val libraryId = id.toUuidOrNull() ?: return null + return when (type) { + "MOVIES" -> Library( + id = libraryId, + name = name, + type = LibraryKind.MOVIES, + posterUrl = posterUrl, + movies = movies?.mapNotNull { it.toMovie() } ?: emptyList(), + ) + "SERIES" -> Library( + id = libraryId, + name = name, + type = LibraryKind.SERIES, + posterUrl = posterUrl, + series = series?.mapNotNull { it.toSeries() } ?: emptyList(), + ) + else -> null + } +} + +fun Media.toCachedItem(): CachedMediaItem = when (this) { + is Media.MovieMedia -> CachedMediaItem(type = "MOVIE", id = movieId.toString()) + is Media.SeriesMedia -> CachedMediaItem(type = "SERIES", id = seriesId.toString()) + is Media.SeasonMedia -> CachedMediaItem(type = "SEASON", id = seasonId.toString(), seriesId = seriesId.toString()) + is Media.EpisodeMedia -> CachedMediaItem(type = "EPISODE", id = episodeId.toString(), seriesId = seriesId.toString()) +} + +fun CachedMediaItem.toMedia(): Media? { + val uuid = runCatching { UUID.fromString(id) }.getOrNull() ?: return null + val seriesUuid = seriesId?.let { runCatching { UUID.fromString(it) }.getOrNull() } + return when (type) { + "MOVIE" -> Media.MovieMedia(movieId = uuid) + "SERIES" -> Media.SeriesMedia(seriesId = uuid) + "SEASON" -> Media.SeasonMedia(seasonId = uuid, seriesId = seriesUuid ?: return null) + "EPISODE" -> Media.EpisodeMedia(episodeId = uuid, seriesId = seriesUuid ?: return null) + else -> null + } +} + +fun Movie.toCachedMovie() = CachedMovie( + id = id.toString(), + libraryId = libraryId.toString(), + title = title, + progress = progress, + watched = watched, + year = year, + rating = rating, + runtime = runtime, + format = format, + synopsis = synopsis, + imageUrlPrefix = imageUrlPrefix, + audioTrack = audioTrack, + subtitles = subtitles, + cast = cast.map { it.toCachedCastMember() }, +) + +fun CachedMovie.toMovie(): Movie? { + val movieId = id.toUuidOrNull() ?: return null + val libraryUuid = libraryId.toUuidOrNull() ?: return null + return Movie( + id = movieId, + libraryId = libraryUuid, + title = title, + progress = progress, + watched = watched, + year = year, + rating = rating, + runtime = runtime, + format = format, + synopsis = synopsis, + imageUrlPrefix = imageUrlPrefix, + audioTrack = audioTrack, + subtitles = subtitles, + cast = cast.map { it.toCastMember() }, + ) +} + +fun Series.toCachedSeries() = CachedSeries( + id = id.toString(), + libraryId = libraryId.toString(), + name = name, + synopsis = synopsis, + year = year, + imageUrlPrefix = imageUrlPrefix, + unwatchedEpisodeCount = unwatchedEpisodeCount, + seasonCount = seasonCount, + cast = cast.map { it.toCachedCastMember() }, +) + +fun CachedSeries.toSeries(): Series? { + val seriesId = id.toUuidOrNull() ?: return null + val libraryUuid = libraryId.toUuidOrNull() ?: return null + return Series( + id = seriesId, + libraryId = libraryUuid, + name = name, + synopsis = synopsis, + year = year, + imageUrlPrefix = imageUrlPrefix, + unwatchedEpisodeCount = unwatchedEpisodeCount, + seasonCount = seasonCount, + seasons = emptyList(), + cast = cast.map { it.toCastMember() }, + ) +} + +fun Episode.toCachedEpisode() = CachedEpisode( + id = id.toString(), + seriesId = seriesId.toString(), + seasonId = seasonId.toString(), + index = index, + title = title, + synopsis = synopsis, + releaseDate = releaseDate, + rating = rating, + runtime = runtime, + progress = progress, + watched = watched, + format = format, + imageUrlPrefix = imageUrlPrefix, + cast = cast.map { it.toCachedCastMember() }, +) + +fun CachedEpisode.toEpisode(): Episode? { + val episodeId = id.toUuidOrNull() ?: return null + val seriesUuid = seriesId.toUuidOrNull() ?: return null + val seasonUuid = seasonId.toUuidOrNull() ?: return null + return Episode( + id = episodeId, + seriesId = seriesUuid, + seasonId = seasonUuid, + index = index, + title = title, + synopsis = synopsis, + releaseDate = releaseDate, + rating = rating, + runtime = runtime, + progress = progress, + watched = watched, + format = format, + imageUrlPrefix = imageUrlPrefix, + cast = cast.map { it.toCastMember() }, + ) +} + +private fun CastMember.toCachedCastMember() = CachedCastMember( + name = name, + role = role, + imageUrl = imageUrl, +) + +private fun CachedCastMember.toCastMember() = CastMember( + name = name, + role = role, + imageUrl = imageUrl, +) + +private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()