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.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile 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.MediaSourceInfo
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
@@ -152,7 +153,7 @@ class JellyfinApiClient @Inject constructor(
} }
val result = api.userLibraryApi.getItem( val result = api.userLibraryApi.getItem(
itemId = mediaId, itemId = mediaId,
userId = getUserId() userId = getUserId(),
) )
Log.d("getItemInfo response: {}", result.content.toString()) Log.d("getItemInfo response: {}", result.content.toString())
return result.content return result.content
@@ -165,6 +166,7 @@ class JellyfinApiClient @Inject constructor(
val result = api.tvShowsApi.getSeasons( val result = api.tvShowsApi.getSeasons(
userId = getUserId(), userId = getUserId(),
seriesId = seriesId, seriesId = seriesId,
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID),
enableUserData = true enableUserData = true
) )
Log.d("getSeasons response: {}", result.content.toString()) Log.d("getSeasons response: {}", result.content.toString())
@@ -179,6 +181,7 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(), userId = getUserId(),
seriesId = seriesId, seriesId = seriesId,
seasonId = seasonId, seasonId = seasonId,
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID),
enableUserData = true enableUserData = true
) )
Log.d("getEpisodesInSeason response: {}", result.content.toString()) 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 { 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.Episode
import hu.bbara.purefin.data.model.Season import hu.bbara.purefin.data.model.Season
import hu.bbara.purefin.data.model.Series import hu.bbara.purefin.data.model.Series
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID
import javax.inject.Inject
class RoomLocalMediaRepository @Inject constructor( class RoomLocalMediaRepository @Inject constructor(
private val seriesDao: SeriesDao, private val seriesDao: SeriesDao,
@@ -147,7 +147,9 @@ private fun SeriesEntity.toDomain(seasons: List<Season>): Series = Series(
year = year, year = year,
heroImageUrl = heroImageUrl, heroImageUrl = heroImageUrl,
seasonCount = seasonCount, seasonCount = seasonCount,
seasons = seasons seasons = seasons,
//TODO check if it is needed
cast = emptyList()
) )
private fun SeasonEntity.toDomain(episodes: List<Episode>): Season = Season( private fun SeasonEntity.toDomain(episodes: List<Episode>): Season = Season(
@@ -171,7 +173,9 @@ private fun EpisodeEntity.toDomain(): Episode = Episode(
format = format, format = format,
synopsis = synopsis, synopsis = synopsis,
heroImageUrl = heroImageUrl, heroImageUrl = heroImageUrl,
cast = cast.map { it.toDomain() } progress = 13.0,
watched = false,
cast = emptyList()
) )
private fun SeriesWithSeasonsAndEpisodes.toDomain(): Series = private fun SeriesWithSeasonsAndEpisodes.toDomain(): Series =
@@ -209,7 +213,7 @@ private fun Episode.toEntity(): EpisodeEntity = EpisodeEntity(
format = format, format = format,
synopsis = synopsis, synopsis = synopsis,
heroImageUrl = heroImageUrl, heroImageUrl = heroImageUrl,
cast = cast.map { it.toEntity() } cast = emptyList()
) )
private fun EpisodeCastMemberEntity.toDomain(): CastMember = CastMember( 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 package hu.bbara.purefin.data.model
import hu.bbara.purefin.app.content.episode.CastMember
import java.util.UUID import java.util.UUID
data class Episode( data class Episode(
val id: UUID, val id: UUID,
val seriesId: UUID, val seriesId: UUID,
val seasonId: UUID, val seasonId: UUID,
val title: String,
val index: Int, val index: Int,
val title: String,
val synopsis: String,
val releaseDate: String, val releaseDate: String,
val rating: String, val rating: String,
val runtime: String, val runtime: String,
val progress: Double?,
val watched: Boolean,
val format: String, val format: String,
val synopsis: String,
val heroImageUrl: String, val heroImageUrl: String,
val cast: List<CastMember> val cast: List<CastMember>
) )

View File

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