From 32d0029379b21c255cef055303e6e907536a5939 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Fri, 30 Jan 2026 15:35:01 +0100 Subject: [PATCH] feat: update MediaRepository and related models to support asynchronous data fetching and add CastMember data class --- .../bbara/purefin/client/JellyfinApiClient.kt | 5 +- .../purefin/data/InMemoryMediaRepository.kt | 282 ++++++++++++++++++ .../hu/bbara/purefin/data/MediaRepository.kt | 12 +- .../repository/RoomLocalMediaRepository.kt | 14 +- .../hu/bbara/purefin/data/model/CastMember.kt | 7 + .../hu/bbara/purefin/data/model/Episode.kt | 7 +- .../hu/bbara/purefin/data/model/Series.kt | 3 +- 7 files changed, 314 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/model/CastMember.kt diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index 4d2481b..035848d 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -20,6 +20,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod @@ -152,7 +153,7 @@ class JellyfinApiClient @Inject constructor( } val result = api.userLibraryApi.getItem( itemId = mediaId, - userId = getUserId() + userId = getUserId(), ) Log.d("getItemInfo response: {}", result.content.toString()) return result.content @@ -165,6 +166,7 @@ class JellyfinApiClient @Inject constructor( val result = api.tvShowsApi.getSeasons( userId = getUserId(), seriesId = seriesId, + fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID), enableUserData = true ) Log.d("getSeasons response: {}", result.content.toString()) @@ -179,6 +181,7 @@ class JellyfinApiClient @Inject constructor( userId = getUserId(), seriesId = seriesId, seasonId = seasonId, + fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID), enableUserData = true ) Log.d("getEpisodesInSeason response: {}", result.content.toString()) diff --git a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt new file mode 100644 index 0000000..c454b2b --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt @@ -0,0 +1,282 @@ +package hu.bbara.purefin.data + +import androidx.collection.LruCache +import hu.bbara.purefin.client.JellyfinApiClient +import hu.bbara.purefin.data.model.Episode +import hu.bbara.purefin.data.model.Season +import hu.bbara.purefin.data.model.Series +import hu.bbara.purefin.image.JellyfinImageHelper +import hu.bbara.purefin.session.UserSessionRepository +import kotlinx.coroutines.flow.first +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( + val userSessionRepository: UserSessionRepository, + val jellyfinApiClient: JellyfinApiClient +) : MediaRepository { + + val seriesCache : LruCache = LruCache(100) + + override suspend fun getSeries( + seriesId: UUID, + includeContent: Boolean + ): Series { + val series = fetchAndUpdateSeriesIfMissing(seriesId) + if (includeContent.not()) { + return series.copy(seasons = emptyList()) + } + + if (hasContent(series)) { + return series + } + + val seasons = getSeasons( + seriesId = seriesId, + includeContent = true + ) + return series.copy(seasons = seasons) + } + + override suspend fun getSeason( + seriesId: UUID, + seasonId: UUID, + includeContent: Boolean + ): Season { + val season = fetchAndUpdateSeasonIfMissing(seriesId, seasonId) + if (includeContent.not()) { + return season.copy(episodes = emptyList()) + } + + if (hasContent(season)) { + return season + } + + val episodes = getEpisodes( + seriesId = seriesId, + seasonId = seasonId + ) + return season.copy(episodes = episodes) + } + + override suspend fun getSeasons( + seriesId: UUID, + includeContent: Boolean + ): List { + val cachedSeasons = fetchAndUpdateSeasonsIfMissing(seriesId) + if (includeContent.not()) { + return cachedSeasons.map { it.copy(episodes = emptyList()) } + } + + val hasContent = cachedSeasons.all { season -> + hasContent(season) + } + if (hasContent) { + return cachedSeasons + } + + return cachedSeasons.map { season -> + // TODO use batch api that gives back all of the episodes in a single request + val episodes = getEpisodes(seriesId, season.id) + season.copy(episodes = episodes) + } + } + + override suspend fun getEpisode( + seriesId: UUID, + seasonId: UUID, + episodeId: UUID + ): Episode { + val cachedSeason = fetchAndUpdateSeasonIfMissing(seriesId, seasonId) + cachedSeason.episodes.find { it.id == episodeId }?.let { + return it + } + + val episodesItemInfo = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId) + val episodes = episodesItemInfo.map { it.toEpisode(serverUrl()) } + val cachedSeries = seriesCache[seriesId]!! + val season = cachedSeason.copy(episodes = episodes) + val updatedSeasons = cachedSeries.seasons.map { if (it.id == seasonId) season else it } + val updatedSeries = cachedSeries.copy(seasons = updatedSeasons) + seriesCache.put(seriesId, updatedSeries) + return episodes.find { it.id == episodeId }!! + } + + override suspend fun getEpisodes( + seriesId: UUID, + seasonId: UUID + ): List { + val cachedSeason = fetchAndUpdateSeasonIfMissing(seriesId, seasonId) + if (hasContent(cachedSeason)) { + return cachedSeason.episodes + } + + val episodesItemInfo = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId) + val episodes = episodesItemInfo.map { it.toEpisode(serverUrl()) } + val cachedSeries = seriesCache[seriesId]!! + val updatedSeason = cachedSeason.copy(episodes = episodes) + val updateSeries = cachedSeries.copy(seasons = cachedSeries.seasons.map { if (it.id == seasonId) updatedSeason else it }) + seriesCache.put(seriesId, updateSeries) + return episodes + } + + override suspend fun getEpisodes(seriesId: UUID): List { + val cachedSeasons = fetchAndUpdateSeasonsIfMissing(seriesId) + if (cachedSeasons.all { hasContent(it) }) { + return cachedSeasons.flatMap { it.episodes } + } + + return cachedSeasons.flatMap { season -> + getEpisodes(seriesId, season.id) + } + } + + private suspend fun fetchAndUpdateSeriesIfMissing(seriesId: UUID): Series { + val cachedSeries = seriesCache[seriesId] + if (cachedSeries == null) { + val seriesItemInfo = jellyfinApiClient.getItemInfo(seriesId) + ?: throw RuntimeException("Series not found") + val series = seriesItemInfo.toSeries(serverUrl()) + seriesCache.put(seriesId, series) + } + return seriesCache[seriesId]!! + } + + private suspend fun fetchAndUpdateSeasonIfMissing(seriesId: UUID, seasonId: UUID): Season { + val cachedSeries = fetchAndUpdateSeriesIfMissing(seriesId) + cachedSeries.seasons.find { it.id == seasonId }?.let { + return it + } + val seasonsItemInfo = jellyfinApiClient.getSeasons(seriesId) + val seasons = seasonsItemInfo.map { it.toSeason(serverUrl()) } + val series = cachedSeries.copy( + seasons = seasons + ) + seriesCache.put(seriesId, series) + return seasons.find { it.id == seasonId }!! + } + + private suspend fun fetchAndUpdateSeasonsIfMissing(seriesId: UUID): List { + val cachedSeries = fetchAndUpdateSeriesIfMissing(seriesId) + if (cachedSeries.seasons.size == cachedSeries.seasonCount) { + return cachedSeries.seasons + } + + val seasonsItemInfo = jellyfinApiClient.getSeasons(seriesId) + val seasons = seasonsItemInfo.map { it.toSeason(serverUrl()) } + val series = cachedSeries.copy( + seasons = seasons + ) + seriesCache.put(seriesId, series) + return seasons + + } + + private fun hasContent(series: Series): Boolean { + if (series.seasons.size != series.seasonCount) { + return false + } + for (season in series.seasons) { + if (hasContent(season).not()) { + return false + } + } + return true + } + + private fun hasContent(season: Season) : Boolean { + return season.episodes.size == season.episodeCount + } + + private suspend fun serverUrl(): String { + return userSessionRepository.serverUrl.first() + } + + private fun BaseItemDto.toSeries(serverUrl: String): Series { + return Series( + id = this.id, + name = this.name ?: "Unknown", + synopsis = this.overview ?: "No synopsis available", + year = this.productionYear?.toString() + ?: this.premiereDate?.year?.toString().orEmpty(), + heroImageUrl = JellyfinImageHelper.toImageUrl( + url = serverUrl, + itemId = this.id, + type = ImageType.PRIMARY + ), + seasonCount = this.childCount!!, + seasons = emptyList(), + cast = emptyList() + ) + } + + private fun BaseItemDto.toSeason(serverUrl: String): Season { + return Season( + id = this.id, + seriesId = this.seriesId!!, + name = this.name ?: "Unknown", + index = this.indexNumber!!, + episodeCount = this.childCount!!, + episodes = emptyList() + ) + } + + 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, + itemId = itemId, + type = ImageType.PRIMARY + ) + } ?: "" + return Episode( + id = id, + seriesId = seriesId!!, + seasonId = parentId!!, + title = name ?: "Unknown title", + index = indexNumber!!, + releaseDate = releaseDate, + rating = rating, + runtime = runtime, + progress = userData!!.playedPercentage, + watched = userData!!.played, + format = format, + synopsis = synopsis, + 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" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt index 9bc4564..ccf465d 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt @@ -7,16 +7,16 @@ import java.util.UUID interface MediaRepository { - fun getSeries(seriesId: UUID, includeContent: Boolean) : Series + suspend fun getSeries(seriesId: UUID, includeContent: Boolean) : Series - fun getSeason(seriesId: UUID, seasonId: UUID, includeContent: Boolean) : Season + suspend fun getSeason(seriesId: UUID, seasonId: UUID, includeContent: Boolean) : Season - fun getSeasons(seriesId: UUID, includeContent: Boolean) : List + suspend fun getSeasons(seriesId: UUID, includeContent: Boolean) : List - fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode + suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode - fun getEpisodes(seriesId: UUID, seasonId: UUID) : List + suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List - fun getEpisodes(seriesId: UUID) : List + suspend fun getEpisodes(seriesId: UUID) : List } \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/data/local/repository/RoomLocalMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/local/repository/RoomLocalMediaRepository.kt index 71a3fbf..e083480 100644 --- a/app/src/main/java/hu/bbara/purefin/data/local/repository/RoomLocalMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/local/repository/RoomLocalMediaRepository.kt @@ -13,12 +13,12 @@ import hu.bbara.purefin.data.local.relations.SeriesWithSeasonsAndEpisodes import hu.bbara.purefin.data.model.Episode import hu.bbara.purefin.data.model.Season import hu.bbara.purefin.data.model.Series -import java.util.UUID -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import java.util.UUID +import javax.inject.Inject class RoomLocalMediaRepository @Inject constructor( private val seriesDao: SeriesDao, @@ -147,7 +147,9 @@ private fun SeriesEntity.toDomain(seasons: List): Series = Series( year = year, heroImageUrl = heroImageUrl, seasonCount = seasonCount, - seasons = seasons + seasons = seasons, + //TODO check if it is needed + cast = emptyList() ) private fun SeasonEntity.toDomain(episodes: List): Season = Season( @@ -171,7 +173,9 @@ private fun EpisodeEntity.toDomain(): Episode = Episode( format = format, synopsis = synopsis, heroImageUrl = heroImageUrl, - cast = cast.map { it.toDomain() } + progress = 13.0, + watched = false, + cast = emptyList() ) private fun SeriesWithSeasonsAndEpisodes.toDomain(): Series = @@ -209,7 +213,7 @@ private fun Episode.toEntity(): EpisodeEntity = EpisodeEntity( format = format, synopsis = synopsis, heroImageUrl = heroImageUrl, - cast = cast.map { it.toEntity() } + cast = emptyList() ) private fun EpisodeCastMemberEntity.toDomain(): CastMember = CastMember( diff --git a/app/src/main/java/hu/bbara/purefin/data/model/CastMember.kt b/app/src/main/java/hu/bbara/purefin/data/model/CastMember.kt new file mode 100644 index 0000000..e25ace4 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/model/CastMember.kt @@ -0,0 +1,7 @@ +package hu.bbara.purefin.data.model + +data class CastMember( + val name: String, + val role: String, + val imageUrl: String? +) \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/data/model/Episode.kt b/app/src/main/java/hu/bbara/purefin/data/model/Episode.kt index d5e06c8..ecc84ac 100644 --- a/app/src/main/java/hu/bbara/purefin/data/model/Episode.kt +++ b/app/src/main/java/hu/bbara/purefin/data/model/Episode.kt @@ -1,19 +1,20 @@ package hu.bbara.purefin.data.model -import hu.bbara.purefin.app.content.episode.CastMember import java.util.UUID data class Episode( val id: UUID, val seriesId: UUID, val seasonId: UUID, - val title: String, val index: Int, + val title: String, + val synopsis: String, val releaseDate: String, val rating: String, val runtime: String, + val progress: Double?, + val watched: Boolean, val format: String, - val synopsis: String, val heroImageUrl: String, val cast: List ) diff --git a/app/src/main/java/hu/bbara/purefin/data/model/Series.kt b/app/src/main/java/hu/bbara/purefin/data/model/Series.kt index fdc9d4a..7f80576 100644 --- a/app/src/main/java/hu/bbara/purefin/data/model/Series.kt +++ b/app/src/main/java/hu/bbara/purefin/data/model/Series.kt @@ -9,5 +9,6 @@ data class Series( val year: String, val heroImageUrl: String, val seasonCount: Int, - val seasons: List + val seasons: List, + val cast: List )