feat: update MediaRepository and related models to support asynchronous data fetching and add CastMember data class

This commit is contained in:
2026-01-30 15:35:01 +01:00
parent 15190a657a
commit 32d0029379
7 changed files with 314 additions and 16 deletions

View File

@@ -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())

View File

@@ -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<UUID, Series> = 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<Season> {
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<Episode> {
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<Episode> {
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<Season> {
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"
}
}
}

View File

@@ -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<Season>
suspend fun getSeasons(seriesId: UUID, includeContent: Boolean) : List<Season>
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<Episode>
suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List<Episode>
fun getEpisodes(seriesId: UUID) : List<Episode>
suspend fun getEpisodes(seriesId: UUID) : List<Episode>
}

View File

@@ -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<Season>): 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<Episode>): 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(

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.data.model
data class CastMember(
val name: String,
val role: String,
val imageUrl: String?
)

View File

@@ -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<CastMember>
)

View File

@@ -9,5 +9,6 @@ data class Series(
val year: String,
val heroImageUrl: String,
val seasonCount: Int,
val seasons: List<Season>
val seasons: List<Season>,
val cast: List<CastMember>
)