implement Room local data source and refactor media repository

- Integrate Room database with entities for `Movie`, `Series`, `Season`, `Episode`, and `CastMember`.
- Implement `RoomMediaLocalDataSource` to handle persistent storage and retrieval of media data.
- Refactor `InMemoryMediaRepository` to use the local data source and synchronize with the Jellyfin API.
- Update `HomePageViewModel`, `SeriesViewModel`, and `EpisodeScreenViewModel` to leverage the new repository logic.
- Replace generic `ItemDto` with specific `MovieDto`, `SeriesDto`, and `EpisodeDto` for type-safe navigation.
- Refactor UI models and components in `SeriesScreen`, `EpisodeScreen`, and `HomeSections` to use domain models directly.
- Enhance `JellyfinApiClient` requests to include necessary fields like `CHILD_COUNT` and `PARENT_ID`.
- Update Gradle dependencies to include Room and KSP.
This commit is contained in:
2026-01-31 21:30:12 +01:00
parent 7cde4b357e
commit fa76517e12
41 changed files with 1289 additions and 567 deletions

View File

@@ -73,6 +73,8 @@ dependencies {
implementation(libs.medi3.ui.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -29,7 +29,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaCastMember
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.MediaSynopsis
@@ -37,6 +36,7 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.MediaPlayButton
import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.player.PlayerActivity
@Composable
@@ -67,7 +67,7 @@ internal fun EpisodeTopBar(
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun EpisodeDetails(
episode: EpisodeUiModel,
episode: Episode,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -91,7 +91,7 @@ internal fun EpisodeDetails(
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Season ${episode.seasonNumber}, Episode ${episode.episodeNumber}",
text = "Episode ${episode.index}",
color = scheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
@@ -153,8 +153,9 @@ internal fun EpisodeDetails(
MediaPlaybackSettings(
backgroundColor = MaterialTheme.colorScheme.surface,
foregroundColor = MaterialTheme.colorScheme.onSurface,
audioTrack = episode.audioTrack,
subtitles = episode.subtitles
//TODO fix it
audioTrack = "ENG",
subtitles = "ENG"
)
Spacer(modifier = Modifier.height(24.dp))
@@ -166,13 +167,8 @@ internal fun EpisodeDetails(
)
Spacer(modifier = Modifier.height(12.dp))
MediaCastRow(
cast = episode.cast.map { it.toMediaCastMember() }
//TODO fix it
cast = emptyList()
)
}
}
private fun CastMember.toMediaCastMember() = MediaCastMember(
name = name,
role = role,
imageUrl = imageUrl
)

View File

@@ -12,23 +12,26 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.content.ContentMockData
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaHero
import hu.bbara.purefin.navigation.ItemDto
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.navigation.EpisodeDto
@Composable
fun EpisodeScreen(
episode: ItemDto,
episode: EpisodeDto,
viewModel: EpisodeScreenViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
LaunchedEffect(episode) {
viewModel.selectEpisode(episode.id)
viewModel.selectEpisode(
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
)
}
val episode = viewModel.episode.collectAsState()
@@ -47,7 +50,7 @@ fun EpisodeScreen(
@Composable
private fun EpisodeScreenInternal(
episode: EpisodeUiModel,
episode: Episode,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -83,12 +86,3 @@ private fun EpisodeScreenInternal(
}
}
}
@Preview
@Composable
fun EpisodeScreenPreview() {
EpisodeScreenInternal(
episode = ContentMockData.episode(),
onBack = {}
)
}

View File

@@ -3,133 +3,39 @@ package hu.bbara.purefin.app.content.episode
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.navigation.ItemDto
import hu.bbara.purefin.data.InMemoryMediaRepository
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class EpisodeScreenViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val mediaRepository: InMemoryMediaRepository,
private val navigationManager: NavigationManager,
private val userSessionRepository: UserSessionRepository
): ViewModel() {
private val _episode = MutableStateFlow<EpisodeUiModel?>(null)
private val _episode = MutableStateFlow<Episode?>(null)
val episode = _episode.asStateFlow()
fun onSeriesSelected(seriesId: String) {
viewModelScope.launch {
navigationManager.navigate(Route.SeriesRoute(ItemDto(id = UUID.fromString(seriesId), type = BaseItemKind.SERIES)))
}
init {
viewModelScope.launch { mediaRepository.ensureReady() }
}
fun onBack() {
navigationManager.pop()
}
fun onGoHome() {
navigationManager.replaceAll(Route.Home)
}
fun selectNextUpEpisodeForSeries(seriesId: UUID) {
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
viewModelScope.launch {
val episode = jellyfinApiClient.getNextUpEpisode(seriesId)
if (episode == null) {
_episode.value = null
return@launch
}
selectEpisodeInternal(episode.id)
}
}
fun selectEpisode(episodeId: UUID) {
viewModelScope.launch {
selectEpisodeInternal(episodeId)
}
}
private suspend fun selectEpisodeInternal(episodeId: UUID) {
val episodeInfo = jellyfinApiClient.getItemInfo(episodeId)
val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank {
"https://jellyfin.bbara.hu"
}
_episode.value = episodeInfo!!.toUiModel(serverUrl)
}
private fun BaseItemDto.toUiModel(serverUrl: String): EpisodeUiModel {
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
_episode.value = mediaRepository.getEpisode(
seriesId = seriesId,
seasonId = seasonId,
episodeId = episodeId,
)
} ?: ""
val cast = people.orEmpty().map { it.toCastMember() }
return EpisodeUiModel(
id = id,
title = name ?: "Unknown title",
seasonNumber = parentIndexNumber!!,
episodeNumber = indexNumber!!,
releaseDate = releaseDate,
rating = rating,
runtime = runtime,
format = format,
synopsis = synopsis,
heroImageUrl = heroImageUrl,
audioTrack = "Default",
subtitles = "Unknown",
cast = cast
)
}
private fun BaseItemPerson.toCastMember(): CastMember {
return CastMember(
name = name ?: "Unknown",
role = role ?: "",
imageUrl = null
)
}
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

@@ -29,7 +29,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaCastMember
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.MediaSynopsis
@@ -160,13 +159,8 @@ internal fun MovieDetails(
)
Spacer(modifier = Modifier.height(12.dp))
MediaCastRow(
cast = movie.cast.map { it.toMediaCastMember() }
//TODO fix it
cast = emptyList()
)
}
}
private fun CastMember.toMediaCastMember() = MediaCastMember(
name = name,
role = role,
imageUrl = imageUrl
)

View File

@@ -18,11 +18,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.content.ContentMockData
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaHero
import hu.bbara.purefin.navigation.ItemDto
import hu.bbara.purefin.navigation.MovieDto
@Composable
fun MovieScreen(
movie: ItemDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier
movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier
) {
LaunchedEffect(movie.id) {
viewModel.selectMovie(movie.id)

View File

@@ -46,12 +46,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaCastMember
import hu.bbara.purefin.common.ui.MediaCastRow
import hu.bbara.purefin.common.ui.MediaMetaChip
import hu.bbara.purefin.common.ui.components.GhostIconButton
import hu.bbara.purefin.common.ui.components.MediaActionButton
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.data.model.CastMember
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Season
import hu.bbara.purefin.data.model.Series
@Composable
internal fun SeriesTopBar(
@@ -79,21 +82,21 @@ internal fun SeriesTopBar(
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SeriesMetaChips(series: SeriesUiModel) {
internal fun SeriesMetaChips(series: Series) {
val scheme = MaterialTheme.colorScheme
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MediaMetaChip(text = series.year)
MediaMetaChip(text = series.rating)
MediaMetaChip(text = series.seasons)
MediaMetaChip(
text = series.format,
background = scheme.primary.copy(alpha = 0.2f),
border = scheme.primary.copy(alpha = 0.3f),
textColor = scheme.primary
)
// MediaMetaChip(text = series.rating)
MediaMetaChip(text = "${series.seasonCount} Seasons")
// MediaMetaChip(
// text = series.,
// background = scheme.primary.copy(alpha = 0.2f),
// border = scheme.primary.copy(alpha = 0.3f),
// textColor = scheme.primary
// )
}
}
@@ -118,10 +121,10 @@ internal fun SeriesActionButtons(modifier: Modifier = Modifier) {
@Composable
internal fun SeasonTabs(
seasons: List<SeriesSeasonUiModel>,
selectedSeason: SeriesSeasonUiModel?,
seasons: List<Season>,
selectedSeason: Season?,
modifier: Modifier = Modifier,
onSelect: (SeriesSeasonUiModel) -> Unit
onSelect: (Season) -> Unit
) {
Row(
modifier = modifier
@@ -170,7 +173,7 @@ private fun SeasonTab(
}
@Composable
internal fun EpisodeCarousel(episodes: List<SeriesEpisodeUiModel>, modifier: Modifier = Modifier) {
internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
LaunchedEffect(episodes) {
@@ -196,14 +199,18 @@ internal fun EpisodeCarousel(episodes: List<SeriesEpisodeUiModel>, modifier: Mod
@Composable
private fun EpisodeCard(
viewModel: SeriesViewModel = hiltViewModel(),
episode: SeriesEpisodeUiModel
episode: Episode
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
Column(
modifier = Modifier
.width(260.dp)
.clickable { viewModel.onSelectEpisode(episode.id) },
.clickable { viewModel.onSelectEpisode(
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
) },
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
@@ -215,7 +222,7 @@ private fun EpisodeCard(
.border(1.dp, scheme.outlineVariant, RoundedCornerShape(12.dp))
) {
PurefinAsyncImage(
model = episode.imageUrl,
model = episode.heroImageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
@@ -241,7 +248,7 @@ private fun EpisodeCard(
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
text = episode.duration,
text = episode.runtime,
color = scheme.onBackground,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
@@ -260,7 +267,7 @@ private fun EpisodeCard(
overflow = TextOverflow.Ellipsis
)
Text(
text = "S${episode.seasonNumber} • E${episode.episodeNumber}",
text = "Episode ${episode.index}",
color = mutedStrong,
fontSize = 12.sp,
maxLines = 1,
@@ -271,18 +278,12 @@ private fun EpisodeCard(
}
@Composable
internal fun CastRow(cast: List<SeriesCastMemberUiModel>, modifier: Modifier = Modifier) {
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
MediaCastRow(
cast = cast.map { it.toMediaCastMember() },
cast = cast,
modifier = modifier,
cardWidth = 84.dp,
nameSize = 11.sp,
roleSize = 10.sp
)
}
private fun SeriesCastMemberUiModel.toMediaCastMember() = MediaCastMember(
name = name,
role = role,
imageUrl = imageUrl
)

View File

@@ -18,19 +18,19 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.app.content.ContentMockData
import hu.bbara.purefin.common.ui.MediaSynopsis
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaHero
import hu.bbara.purefin.navigation.ItemDto
import hu.bbara.purefin.data.model.Season
import hu.bbara.purefin.data.model.Series
import hu.bbara.purefin.navigation.SeriesDto
@Composable
fun SeriesScreen(
series: ItemDto,
series: SeriesDto,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel()
) {
@@ -53,15 +53,15 @@ fun SeriesScreen(
@Composable
private fun SeriesScreenInternal(
series: SeriesUiModel,
series: Series,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val textMutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
fun getDefaultSeason() : SeriesSeasonUiModel {
for (season in series.seasonTabs) {
fun getDefaultSeason() : Season {
for (season in series.seasons) {
val firstUnwatchedEpisode = season.episodes.firstOrNull {
it.watched.not()
}
@@ -69,9 +69,9 @@ private fun SeriesScreenInternal(
return season
}
}
return series.seasonTabs.first()
return series.seasons.first()
}
val selectedSeason = remember { mutableStateOf<SeriesSeasonUiModel>(getDefaultSeason()) }
val selectedSeason = remember { mutableStateOf<Season>(getDefaultSeason()) }
Scaffold(
modifier = modifier,
@@ -101,7 +101,7 @@ private fun SeriesScreenInternal(
.padding(bottom = innerPadding.calculateBottomPadding())
) {
Text(
text = series.title,
text = series.name,
color = scheme.onBackground,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
@@ -121,12 +121,12 @@ private fun SeriesScreenInternal(
)
Spacer(modifier = Modifier.height(24.dp))
SeasonTabs(
seasons = series.seasonTabs,
seasons = series.seasons,
selectedSeason = selectedSeason.value,
onSelect = { selectedSeason.value = it }
)
EpisodeCarousel(
episodes = selectedSeason.value.episodes
episodes = selectedSeason.value.episodes,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
@@ -141,12 +141,3 @@ private fun SeriesScreenInternal(
}
}
}
@Preview
@Composable
fun SeriesScreenPreview() {
SeriesScreenInternal(
series = ContentMockData.series(),
onBack = {}
)
}

View File

@@ -3,36 +3,39 @@ package hu.bbara.purefin.app.content.series
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.navigation.ItemDto
import hu.bbara.purefin.data.InMemoryMediaRepository
import hu.bbara.purefin.data.model.Series
import hu.bbara.purefin.navigation.EpisodeDto
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.BaseItemPerson
import org.jellyfin.sdk.model.api.ImageType
import javax.inject.Inject
@HiltViewModel
class SeriesViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val mediaRepository: InMemoryMediaRepository,
private val navigationManager: NavigationManager,
private val userSessionRepository: UserSessionRepository
) : ViewModel() {
private val _series = MutableStateFlow<SeriesUiModel?>(null)
private val _series = MutableStateFlow<Series?>(null)
val series = _series.asStateFlow()
fun onSelectEpisode(episodeId: String) {
init {
viewModelScope.launch { mediaRepository.ensureReady() }
}
fun onSelectEpisode(seriesId: UUID, seasonId:UUID, episodeId: UUID) {
viewModelScope.launch {
navigationManager.navigate(Route.EpisodeRoute(ItemDto(id = UUID.fromString(episodeId), type = BaseItemKind.EPISODE)))
navigationManager.navigate(Route.EpisodeRoute(
EpisodeDto(
id = episodeId,
seasonId = seasonId,
seriesId = seriesId
)
))
}
}
@@ -47,74 +50,10 @@ class SeriesViewModel @Inject constructor(
fun selectSeries(seriesId: UUID) {
viewModelScope.launch {
val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank {
"https://jellyfin.bbara.hu"
}
val seriesItemResult = jellyfinApiClient.getItemInfo(mediaId = seriesId)
val seasonsItemResult = jellyfinApiClient.getSeasons(seriesId)
val episodesItemResult = seasonsItemResult.associate { season ->
season.id to jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
}
val seriesUiModel = mapToSeriesUiModel(
serverUrl,
seriesItemResult,
seasonsItemResult,
episodesItemResult
val series = mediaRepository.getSeriesWithContent(
seriesId = seriesId
)
_series.value = seriesUiModel
_series.value = series
}
}
private fun mapToSeriesUiModel(
serverUrl: String,
seriesItemResult: BaseItemDto?,
seasonsItemResult: List<BaseItemDto>,
episodesItemResult: Map<UUID, List<BaseItemDto>>
): SeriesUiModel {
val seasonUiModels = seasonsItemResult.map { season ->
val episodeItemResult = episodesItemResult[season.id] ?: emptyList()
val episodeItemUiModels = episodeItemResult.map { episode ->
SeriesEpisodeUiModel(
id = episode.id.toString(),
title = episode.name ?: "Unknown",
seasonNumber = episode.parentIndexNumber!!,
episodeNumber = episode.indexNumber!!,
description = episode.overview ?: "",
duration = "58m",
imageUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = episode.id, type = ImageType.PRIMARY),
progress = episode.userData!!.playedPercentage,
watched = episode.userData!!.played
)
}
SeriesSeasonUiModel(
name = season.name ?: "Unknown",
episodes = episodeItemUiModels,
unplayedCount = season.userData!!.unplayedItemCount
)
}
return SeriesUiModel(
title = seriesItemResult?.name ?: "Unknown",
format = seriesItemResult?.container ?: "VIDEO",
rating = seriesItemResult?.officialRating ?: "NR",
year = seriesItemResult!!.productionYear?.toString() ?: seriesItemResult!!.premiereDate?.year?.toString().orEmpty(),
seasons = "3 Seasons",
synopsis = seriesItemResult.overview ?: "No synopsis available.",
heroImageUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = seriesItemResult.id,
type = ImageType.PRIMARY
),
seasonTabs = seasonUiModels,
cast = seriesItemResult.people.orEmpty().map { it.toCastMember() }
)
}
private fun BaseItemPerson.toCastMember(): SeriesCastMemberUiModel {
return SeriesCastMemberUiModel(
name = name ?: "Unknown",
role = role ?: "",
imageUrl = null
)
}
}
}

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.app.home
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -9,15 +8,21 @@ import hu.bbara.purefin.app.home.ui.HomeNavItem
import hu.bbara.purefin.app.home.ui.LibraryItem
import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.data.InMemoryMediaRepository
import hu.bbara.purefin.data.model.Media
import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.navigation.ItemDto
import hu.bbara.purefin.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -25,12 +30,11 @@ import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import javax.inject.Inject
@HiltViewModel
class HomePageViewModel @Inject constructor(
private val mediaRepository: InMemoryMediaRepository,
private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager,
private val jellyfinApiClient: JellyfinApiClient
@@ -42,19 +46,44 @@ class HomePageViewModel @Inject constructor(
initialValue = ""
)
private val _continueWatching = MutableStateFlow<List<ContinueWatchingItem>>(emptyList())
val continueWatching = _continueWatching.asStateFlow()
private val _libraries = MutableStateFlow<List<LibraryItem>>(emptyList())
val libraries = _libraries.asStateFlow()
private val _libraryItems = MutableStateFlow<Map<UUID, List<PosterItem>>>(emptyMap())
val libraryItems = _libraryItems.asStateFlow()
val continueWatching = mediaRepository.continueWatching.map { list ->
list.map {
when ( it ) {
is Media.MovieMedia -> {
val movie = mediaRepository.getMovie(it.movieId)
ContinueWatchingItem(
type = BaseItemKind.MOVIE,
movie = movie
)
}
is Media.EpisodeMedia -> {
val episode = mediaRepository.getEpisode(
seriesId = it.seriesId,
episodeId = it.episodeId
)
ContinueWatchingItem(
type = BaseItemKind.EPISODE,
episode = episode
)
}
else -> throw UnsupportedOperationException("Unsupported item type: $it")
}
}
}.distinctUntilChanged()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
private val _latestLibraryContent = MutableStateFlow<Map<UUID, List<PosterItem>>>(emptyMap())
val latestLibraryContent = _latestLibraryContent.asStateFlow()
init {
viewModelScope.launch { mediaRepository.ensureReady() }
loadHomePageData()
}
@@ -64,19 +93,33 @@ class HomePageViewModel @Inject constructor(
}
}
fun onMovieSelected(movieId: String) {
navigationManager.navigate(Route.MovieRoute(ItemDto(id = UUID.fromString(movieId), type = BaseItemKind.MOVIE)))
fun onMovieSelected(movieId: UUID) {
navigationManager.navigate(Route.MovieRoute(
MovieDto(
id = movieId,
)
))
}
fun onSeriesSelected(seriesId: String) {
fun onSeriesSelected(seriesId: UUID) {
viewModelScope.launch {
navigationManager.navigate(Route.SeriesRoute(ItemDto(id = UUID.fromString(seriesId), type = BaseItemKind.SERIES)))
navigationManager.navigate(Route.SeriesRoute(
SeriesDto(
id = seriesId,
)
))
}
}
fun onEpisodeSelected(episodeId: String) {
fun onEpisodeSelected(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
viewModelScope.launch {
navigationManager.navigate(Route.EpisodeRoute(ItemDto(id = UUID.fromString(episodeId), type = BaseItemKind.EPISODE)))
navigationManager.navigate(Route.EpisodeRoute(
EpisodeDto(
id = episodeId,
seasonId = seasonId,
seriesId = seriesId
)
))
}
}
@@ -91,35 +134,13 @@ class HomePageViewModel @Inject constructor(
fun loadContinueWatching() {
viewModelScope.launch {
val continueWatching: List<BaseItemDto> = jellyfinApiClient.getContinueWatching()
_continueWatching.value = continueWatching.map {
if (it.type == BaseItemKind.EPISODE) {
ContinueWatchingItem(
id = it.id,
type = BaseItemKind.EPISODE,
primaryText = it.seriesName!!,
secondaryText = "S${it.parentIndexNumber!!}:${it.indexNumber!!} - ${it.name!!}",
progress = it.userData!!.playedPercentage!!,
colors = listOf(Color.Red, Color.Green),
)
} else {
ContinueWatchingItem(
id = it.id,
type = BaseItemKind.MOVIE,
primaryText = it.name!!,
secondaryText = it.premiereDate!!.format(DateTimeFormatter.ofLocalizedDate(
FormatStyle.MEDIUM)),
progress = it.userData!!.playedPercentage!!,
colors = listOf(Color.Red, Color.Green)
)
}
}
// mediaRepository.loadContinueWatching()
}
}
fun loadLibraries() {
viewModelScope.launch {
loadLibrariesInternal()
// mediaRepository.loadLibraries()
}
}
@@ -136,35 +157,6 @@ class HomePageViewModel @Inject constructor(
_libraries.value = mappedLibraries
}
fun loadAllLibraryItems() {
viewModelScope.launch {
if (_libraries.value.isEmpty()) {
loadLibrariesInternal()
}
_libraries.value.forEach { library ->
loadLibraryItems(library.id)
}
}
}
private fun loadLibraryItems(libraryId: UUID) {
viewModelScope.launch {
val libraryItems: List<BaseItemDto> = jellyfinApiClient.getLibrary(libraryId)
// It return only Movie or Series
val libraryPosterItems = libraryItems.map {
PosterItem(
id = it.id,
title = it.name ?: "Unknown",
type = it.type,
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
)
}
_libraryItems.update { currentMap ->
currentMap + (libraryId to libraryPosterItems)
}
}
}
fun loadAllShownLibraryItems() {
viewModelScope.launch {
if (_libraries.value.isEmpty()) {
@@ -177,30 +169,47 @@ class HomePageViewModel @Inject constructor(
}
fun loadLatestLibraryItems(libraryId: UUID) {
if (_libraryItems.value.containsKey(libraryId)) return
viewModelScope.launch {
val latestLibraryItems = jellyfinApiClient.getLatestFromLibrary(libraryId)
val latestLibraryPosterItem = latestLibraryItems.mapNotNull {
val latestLibraryPosterItem = latestLibraryItems.map {
when (it.type) {
BaseItemKind.MOVIE -> PosterItem(
id = it.id,
title = it.name ?: "Unknown",
type = BaseItemKind.MOVIE,
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
)
BaseItemKind.EPISODE -> PosterItem(
id = it.id,
title = it.seriesName ?: "Unknown",
type = BaseItemKind.EPISODE,
imageUrl = getImageUrl(it.parentId!!, ImageType.PRIMARY)
)
BaseItemKind.SEASON -> PosterItem(
id = it.seriesId!!,
title = it.seriesName ?: "Unknown",
type = BaseItemKind.SERIES,
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
)
else -> null
BaseItemKind.MOVIE -> {
val movie = mediaRepository.getMovie(it.id)
PosterItem(
type = BaseItemKind.MOVIE,
movie = movie
)
}
BaseItemKind.EPISODE -> {
val episode = mediaRepository.getEpisode(
it.seriesId!!,
it.parentId!!,
it.id
)
PosterItem(
type = BaseItemKind.EPISODE,
episode = episode
)
}
BaseItemKind.SEASON -> {
val series = mediaRepository.getSeries(
seriesId = it.seriesId!!
)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
}
BaseItemKind.SERIES -> {
val series = mediaRepository.getSeries(
seriesId = it.id
)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
}
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
}
}.distinctBy { it.id }
_latestLibraryContent.update { currentMap ->
@@ -211,8 +220,6 @@ class HomePageViewModel @Inject constructor(
fun loadHomePageData() {
loadContinueWatching()
loadLibraries()
loadAllLibraryItems()
loadAllShownLibraryItems()
}

View File

@@ -1,19 +1,39 @@
package hu.bbara.purefin.app.home.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Movie
import hu.bbara.purefin.data.model.Series
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
data class ContinueWatchingItem(
val id: UUID,
val type: BaseItemKind,
val primaryText: String,
val secondaryText: String,
val progress: Double,
val colors: List<Color>
)
val movie: Movie? = null,
val episode: Episode? = null
) {
val id: UUID = when (type) {
BaseItemKind.MOVIE -> movie!!.id
BaseItemKind.EPISODE -> episode!!.id
else -> throw UnsupportedOperationException("Unsupported item type: $type")
}
val primaryText: String = when (type) {
BaseItemKind.MOVIE -> movie!!.title
BaseItemKind.EPISODE -> episode!!.title
else -> throw UnsupportedOperationException("Unsupported item type: $type")
}
val secondaryText: String = when (type) {
BaseItemKind.MOVIE -> movie!!.year
BaseItemKind.EPISODE -> episode!!.releaseDate
else -> throw UnsupportedOperationException("Unsupported item type: $type")
}
val progress: Double = when (type) {
BaseItemKind.MOVIE -> movie!!.progress ?: 0.0
BaseItemKind.EPISODE -> episode!!.progress ?: 0.0
else -> throw UnsupportedOperationException("Unsupported item type: $type")
}
}
data class LibraryItem(
val id: UUID,
@@ -23,11 +43,31 @@ data class LibraryItem(
)
data class PosterItem(
val id: UUID,
val title: String,
val type: BaseItemKind,
val imageUrl: String
)
val movie: Movie? = null,
val series: Series? = null,
val episode: Episode? = null
) {
val id: UUID = when (type) {
BaseItemKind.MOVIE -> movie!!.id
BaseItemKind.EPISODE -> episode!!.id
BaseItemKind.SERIES -> series!!.id
else -> throw IllegalArgumentException("Invalid type: $type")
}
val title: String = when (type) {
BaseItemKind.MOVIE -> movie!!.title
BaseItemKind.EPISODE -> episode!!.title
BaseItemKind.SERIES -> series!!.name
else -> throw IllegalArgumentException("Invalid type: $type")
}
val imageUrl: String = when (type) {
BaseItemKind.MOVIE -> movie!!.heroImageUrl
BaseItemKind.EPISODE -> episode!!.heroImageUrl
BaseItemKind.SERIES -> series!!.heroImageUrl
else -> throw IllegalArgumentException("Invalid type: $type")
}
}
data class HomeNavItem(
val id: UUID,

View File

@@ -83,8 +83,16 @@ fun ContinueWatchingCard(
fun openItem(item: ContinueWatchingItem) {
when (item.type) {
BaseItemKind.MOVIE -> viewModel.onMovieSelected(item.id.toString())
BaseItemKind.EPISODE -> viewModel.onEpisodeSelected(item.id.toString())
BaseItemKind.MOVIE -> viewModel.onMovieSelected(item.movie!!.id)
BaseItemKind.EPISODE -> {
val episode = item.episode!!
viewModel.onEpisodeSelected(
seriesId = episode.seriesId,
seasonId = episode.seasonId,
episodeId = episode.id
)
}
else -> {}
}
}
@@ -128,7 +136,8 @@ fun ContinueWatchingCard(
)
}
IconButton(
modifier = Modifier.align(Alignment.BottomEnd)
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 8.dp, bottom = 16.dp)
.clip(CircleShape)
.background(scheme.secondary)
@@ -193,9 +202,15 @@ fun LibraryPosterSection(
items = items, key = { it.id }) { item ->
PosterCard(
item = item,
onMovieSelected = { viewModel.onMovieSelected(it) },
onSeriesSelected = { viewModel.onSeriesSelected(it) },
onEpisodeSelected = { viewModel.onEpisodeSelected(it) }
onMovieSelected = { viewModel.onMovieSelected(item.movie!!.id) },
onSeriesSelected = { viewModel.onSeriesSelected(item.series!!.id) },
onEpisodeSelected = {
viewModel.onEpisodeSelected(
seriesId = item.episode!!.seriesId,
seasonId = item.episode.seasonId,
episodeId = item.episode.id
)
}
)
}
}

View File

@@ -5,10 +5,12 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.app.home.ui.PosterItem
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.data.InMemoryMediaRepository
import hu.bbara.purefin.image.JellyfinImageHelper
import hu.bbara.purefin.navigation.ItemDto
import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.session.UserSessionRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -22,6 +24,7 @@ import javax.inject.Inject
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val mediaRepository: InMemoryMediaRepository,
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient,
private val navigationManager: NavigationManager
@@ -35,13 +38,25 @@ class LibraryViewModel @Inject constructor(
private val _contents = MutableStateFlow<List<PosterItem>>(emptyList())
val contents = _contents.asStateFlow()
init {
viewModelScope.launch { mediaRepository.ensureReady() }
}
fun onMovieSelected(movieId: String) {
navigationManager.navigate(Route.MovieRoute(ItemDto(id = UUID.fromString(movieId), type = BaseItemKind.MOVIE)))
navigationManager.navigate(Route.MovieRoute(
MovieDto(
id = UUID.fromString(movieId),
)
))
}
fun onSeriesSelected(seriesId: String) {
viewModelScope.launch {
navigationManager.navigate(Route.SeriesRoute(ItemDto(id = UUID.fromString(seriesId), type = BaseItemKind.SERIES)))
navigationManager.navigate(Route.SeriesRoute(
SeriesDto(
id = UUID.fromString(seriesId),
)
))
}
}
@@ -51,14 +66,25 @@ class LibraryViewModel @Inject constructor(
fun selectLibrary(libraryId: UUID) {
viewModelScope.launch {
val libraryItems = jellyfinApiClient.getLibrary(libraryId)
val libraryItems = jellyfinApiClient.getLibraryContent(libraryId)
_contents.value = libraryItems.map {
PosterItem(
id = it.id,
title = it.name ?: "Unknown",
type = it.type,
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
)
when (it.type) {
BaseItemKind.MOVIE -> {
val movie = mediaRepository.getMovie(it.id)
PosterItem(
type = BaseItemKind.MOVIE,
movie = movie
)
}
BaseItemKind.SERIES -> {
val series = mediaRepository.getSeries(it.id)
PosterItem(
type = BaseItemKind.SERIES,
series = series
)
}
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
}
}
}
}
@@ -70,4 +96,4 @@ class LibraryViewModel @Inject constructor(
type = type
)
}
}
}

View File

@@ -19,6 +19,7 @@ import org.jellyfin.sdk.model.ClientInfo
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.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.MediaSourceInfo
@@ -89,6 +90,9 @@ class JellyfinApiClient @Inject constructor(
}
val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId,
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED),
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
enableUserData = true,
startIndex = 0,
)
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
@@ -102,13 +106,15 @@ class JellyfinApiClient @Inject constructor(
}
val response = api.userViewsApi.getUserViews(
userId = getUserId(),
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
includeHidden = false,
)
Log.d("getLibraries response: {}", response.content.toString())
return response.content.items
val libraries = response.content.items
return libraries
}
suspend fun getLibrary(libraryId: UUID): List<BaseItemDto> {
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> {
if (!ensureConfigured()) {
return emptyList()
}
@@ -116,14 +122,13 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(),
enableImages = false,
parentId = libraryId,
enableUserData = false,
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED),
enableUserData = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
// recursive = true,
// TODO remove this limit
// limit = 10
recursive = true,
)
val response = api.itemsApi.getItems(getItemsRequest)
Log.d("getLibrary response: {}", response.content.toString())
Log.d("getLibraryContent response: {}", response.content.toString())
return response.content.items
}
@@ -140,6 +145,7 @@ class JellyfinApiClient @Inject constructor(
val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(),
parentId = libraryId,
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED),
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
limit = 10
)
@@ -166,7 +172,7 @@ class JellyfinApiClient @Inject constructor(
val result = api.tvShowsApi.getSeasons(
userId = getUserId(),
seriesId = seriesId,
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID),
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED),
enableUserData = true
)
Log.d("getSeasons response: {}", result.content.toString())
@@ -181,7 +187,7 @@ class JellyfinApiClient @Inject constructor(
userId = getUserId(),
seriesId = seriesId,
seasonId = seasonId,
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID),
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED),
enableUserData = true
)
Log.d("getEpisodesInSeason response: {}", result.content.toString())

View File

@@ -34,6 +34,7 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.data.model.CastMember
@Composable
fun MediaMetaChip(
@@ -62,15 +63,9 @@ fun MediaMetaChip(
}
}
data class MediaCastMember(
val name: String,
val role: String,
val imageUrl: String?
)
@Composable
fun MediaCastRow(
cast: List<MediaCastMember>,
cast: List<CastMember>,
modifier: Modifier = Modifier,
cardWidth: Dp = 96.dp,
nameSize: TextUnit = 12.sp,

View File

@@ -1,14 +1,26 @@
package hu.bbara.purefin.data
import androidx.collection.LruCache
import hu.bbara.purefin.client.JellyfinApiClient
import hu.bbara.purefin.data.local.room.RoomMediaLocalDataSource
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Library
import hu.bbara.purefin.data.model.Media
import hu.bbara.purefin.data.model.Movie
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.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@@ -21,73 +33,201 @@ import javax.inject.Singleton
@Singleton
class InMemoryMediaRepository @Inject constructor(
val userSessionRepository: UserSessionRepository,
val jellyfinApiClient: JellyfinApiClient
val jellyfinApiClient: JellyfinApiClient,
private val localDataSource: RoomMediaLocalDataSource
) : MediaRepository {
val seriesCache : LruCache<UUID, Series> = LruCache(100)
private val ready = CompletableDeferred<Unit>()
override suspend fun getSeries(
seriesId: UUID,
includeContent: Boolean
): Series {
val series = fetchAndUpdateSeriesIfMissing(seriesId)
if (includeContent.not()) {
return series.copy(seasons = emptyList())
private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Loading)
override val state: StateFlow<MediaRepositoryState> = _state
private val _movies : MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap())
override val movies: StateFlow<Map<UUID, Movie>> = _movies
private val _series : MutableStateFlow<Map<UUID, Series>> = MutableStateFlow(emptyMap())
override val series: StateFlow<Map<UUID, Series>> = _series
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
val continueWatching: StateFlow<List<Media>> = _continueWatching
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
scope.launch {
runCatching { ensureReady() }
}
}
override suspend fun ensureReady() {
if (ready.isCompleted) return
try {
loadLibraries()
loadContinueWatching()
_state.value = MediaRepositoryState.Ready
ready.complete(Unit)
} catch (t: Throwable) {
_state.value = MediaRepositoryState.Error(t)
ready.completeExceptionally(t)
throw t
}
}
private suspend fun awaitReady() {
ready.await()
}
suspend fun loadLibraries() {
val librariesItem = jellyfinApiClient.getLibraries()
//TODO add support for playlists
val filteredLibraries =
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
val emptyLibraries = filteredLibraries.map {
it.toLibrary()
}
val filledLibraries = emptyLibraries.map { library ->
return@map loadLibrary(library)
}
val movies = filledLibraries.filter { it.type == CollectionType.MOVIES }.flatMap { it.movies.orEmpty() }
localDataSource.saveMovies(movies)
_movies.value = localDataSource.getMovies().associateBy { it.id }
val series = filledLibraries.filter { it.type == CollectionType.TVSHOWS }.flatMap { it.series.orEmpty() }
localDataSource.saveSeries(series)
_series.value = localDataSource.getSeries().associateBy { it.id }
}
suspend fun loadLibrary(library: Library): Library {
val contentItem = jellyfinApiClient.getLibraryContent(library.id)
when (library.type) {
CollectionType.MOVIES -> {
val movies = contentItem.map { it.toMovie(serverUrl(), library.id) }
return library.copy(movies = movies)
}
CollectionType.TVSHOWS -> {
val series = contentItem.map { it.toSeries(serverUrl(), library.id) }
return library.copy(series = series)
}
else -> throw UnsupportedOperationException("Unsupported library type: ${library.type}")
}
}
suspend fun loadMovie(movie: Movie) : Movie {
val movieItem = jellyfinApiClient.getItemInfo(movie.id)
?: throw RuntimeException("Movie not found")
val updatedMovie = movieItem.toMovie(serverUrl(), movie.libraryId)
localDataSource.saveMovies(listOf(updatedMovie))
_movies.value += (movie.id to updatedMovie)
return updatedMovie
}
suspend fun loadSeries(series: Series) : Series {
val seriesItem = jellyfinApiClient.getItemInfo(series.id)
?: throw RuntimeException("Series not found")
val updatedSeries = seriesItem.toSeries(serverUrl(), series.libraryId)
localDataSource.saveSeries(listOf(updatedSeries))
_series.value += (series.id to updatedSeries)
return updatedSeries
}
suspend fun loadContinueWatching() {
val continueWatchingItems = jellyfinApiClient.getContinueWatching()
val items = continueWatchingItems.mapNotNull { item ->
when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
BaseItemKind.EPISODE -> Media.EpisodeMedia(
episodeId = item.id,
seriesId = item.seriesId!!
)
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
}
}
_continueWatching.value = items
//Load episodes, Movies are already loaded at this point.
continueWatchingItems.forEach { item ->
when (item.type) {
BaseItemKind.EPISODE -> {
val episode = item.toEpisode(serverUrl())
localDataSource.saveEpisode(episode)
}
else -> { /* Do nothing */ }
}
}
}
override suspend fun getMovie(movieId: UUID): Movie {
awaitReady()
localDataSource.getMovie(movieId)?.let {
_movies.value += (movieId to it)
return it
}
throw RuntimeException("Movie not found")
}
override suspend fun getSeries(seriesId: UUID): Series {
awaitReady()
localDataSource.getSeriesBasic(seriesId)?.let {
_series.value += (seriesId to it)
return it
}
throw RuntimeException("Series not found")
}
override suspend fun getSeriesWithContent(seriesId: UUID): Series {
awaitReady()
// Use cached content if available
localDataSource.getSeriesWithContent(seriesId)?.takeIf { it.seasons.isNotEmpty() }?.let {
_series.value += (seriesId to it)
return it
}
if (hasContent(series)) {
return series
}
val series = _series.value[seriesId] ?: throw RuntimeException("Series not found")
val seasons = getSeasons(
seriesId = seriesId,
includeContent = true
)
return series.copy(seasons = seasons)
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)
localDataSource.saveSeries(listOf(updatedSeries))
localDataSource.saveSeriesContent(updatedSeries)
_series.value += (series.id to updatedSeries)
return updatedSeries
}
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)
awaitReady()
localDataSource.getSeason(seriesId, seasonId)?.let { return it }
// Fallback: ensure series content is loaded, then retry
val series = getSeriesWithContent(seriesId)
return series.seasons.find { it.id == seasonId }?: throw RuntimeException("Season not found")
}
override suspend fun getSeasons(
seriesId: UUID,
includeContent: Boolean
): List<Season> {
val cachedSeasons = fetchAndUpdateSeasonsIfMissing(seriesId)
if (includeContent.not()) {
return cachedSeasons.map { it.copy(episodes = emptyList()) }
}
awaitReady()
val seasons = localDataSource.getSeasons(seriesId)
if (seasons.isNotEmpty()) return seasons
val series = getSeriesWithContent(seriesId)
return series.seasons
}
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,
episodeId: UUID
) : Episode {
awaitReady()
localDataSource.getEpisodeById(episodeId)?.let { return it }
val series = getSeriesWithContent(seriesId)
return series.seasons.flatMap { it.episodes }.find { it.id == episodeId }?: throw RuntimeException("Episode not found")
}
override suspend fun getEpisode(
@@ -95,114 +235,81 @@ class InMemoryMediaRepository @Inject constructor(
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 }!!
awaitReady()
localDataSource.getEpisode(seriesId, seasonId, episodeId)?.let { return it }
val series = getSeriesWithContent(seriesId)
return series.seasons.find { it.id == seasonId }?.episodes?.find { it.id == episodeId } ?: throw RuntimeException("Episode not found")
}
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
awaitReady()
val episodes = localDataSource.getSeason(seriesId, seasonId)?.episodes
if (episodes != null && episodes.isNotEmpty()) return episodes
val series = getSeriesWithContent(seriesId)
return series.seasons.find { it.id == seasonId }?.episodes ?: throw RuntimeException("Season not found")
}
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
awaitReady()
val episodes = localDataSource.getEpisodesBySeries(seriesId)
if (episodes.isNotEmpty()) return episodes
val series = getSeriesWithContent(seriesId)
return series.seasons.flatMap { it.episodes }
}
private suspend fun serverUrl(): String {
return userSessionRepository.serverUrl.first()
}
private fun BaseItemDto.toSeries(serverUrl: String): Series {
private fun BaseItemDto.toLibrary(): Library {
return when (this.collectionType) {
CollectionType.MOVIES -> Library(
id = this.id,
name = this.name!!,
type = CollectionType.MOVIES,
movies = emptyList()
)
CollectionType.TVSHOWS -> Library(
id = this.id,
name = this.name!!,
type = CollectionType.TVSHOWS,
series = emptyList()
)
else -> throw UnsupportedOperationException("Unsupported library type: ${this.collectionType}")
}
}
private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID) : Movie {
return Movie(
id = this.id,
libraryId = libraryId,
title = this.name ?: "Unknown title",
progress = this.userData!!.playedPercentage,
watched = this.userData!!.played,
year = this.productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
rating = this.officialRating
?: "NR",
runtime = formatRuntime(this.runTimeTicks),
synopsis = this.overview ?: "No synopsis available",
format = container?.uppercase() ?: "VIDEO",
heroImageUrl = JellyfinImageHelper.toImageUrl(
url = serverUrl,
itemId = this.id,
type = ImageType.PRIMARY
),
subtitles = "ENG",
audioTrack = "ENG",
cast = emptyList()
)
}
private fun BaseItemDto.toSeries(serverUrl: String, libraryId: UUID): Series {
return Series(
id = this.id,
libraryId = libraryId,
name = this.name ?: "Unknown",
synopsis = this.overview ?: "No synopsis available",
year = this.productionYear?.toString()
@@ -279,4 +386,4 @@ class InMemoryMediaRepository @Inject constructor(
"${minutes}m"
}
}
}
}

View File

@@ -1,22 +1,28 @@
package hu.bbara.purefin.data
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Movie
import hu.bbara.purefin.data.model.Season
import hu.bbara.purefin.data.model.Series
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
interface MediaRepository {
suspend fun getSeries(seriesId: UUID, includeContent: Boolean) : Series
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
val state: StateFlow<MediaRepositoryState>
suspend fun getSeason(seriesId: UUID, seasonId: UUID, includeContent: Boolean) : Season
suspend fun getSeasons(seriesId: UUID, includeContent: Boolean) : List<Season>
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode
suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List<Episode>
suspend fun ensureReady()
suspend fun getMovie(movieId: UUID) : Movie
suspend fun getSeries(seriesId: UUID) : Series
suspend fun getSeriesWithContent(seriesId: UUID) : Series
suspend fun getSeasons(seriesId: UUID) : List<Season>
suspend fun getSeason(seriesId: UUID, seasonId: UUID) : Season
suspend fun getEpisodes(seriesId: UUID) : List<Episode>
suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List<Episode>
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode
suspend fun getEpisode(seriesId: UUID, episodeId: UUID) : Episode
}
}

View File

@@ -0,0 +1,7 @@
package hu.bbara.purefin.data
sealed interface MediaRepositoryState {
data object Loading : MediaRepositoryState
data object Ready : MediaRepositoryState
data class Error(val throwable: Throwable) : MediaRepositoryState
}

View File

@@ -0,0 +1,20 @@
package hu.bbara.purefin.data.local.room
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(
tableName = "cast_members",
indices = [Index("movieId"), Index("seriesId"), Index("episodeId")]
)
data class CastMemberEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val role: String,
val imageUrl: String?,
val movieId: UUID? = null,
val seriesId: UUID? = null,
val episodeId: UUID? = null
)

View File

@@ -0,0 +1,35 @@
package hu.bbara.purefin.data.local.room
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(
tableName = "episodes",
foreignKeys = [
ForeignKey(
entity = SeriesEntity::class,
parentColumns = ["id"],
childColumns = ["seriesId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("seriesId"), Index("seasonId")]
)
data class EpisodeEntity(
@PrimaryKey val id: UUID,
val seriesId: UUID,
val seasonId: UUID?,
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 heroImageUrl: String
)

View File

@@ -0,0 +1,30 @@
package hu.bbara.purefin.data.local.room
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import hu.bbara.purefin.data.local.room.dao.CastMemberDao
import hu.bbara.purefin.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.data.local.room.dao.MovieDao
import hu.bbara.purefin.data.local.room.dao.SeasonDao
import hu.bbara.purefin.data.local.room.dao.SeriesDao
@Database(
entities = [
MovieEntity::class,
SeriesEntity::class,
SeasonEntity::class,
EpisodeEntity::class,
CastMemberEntity::class
],
version = 1,
exportSchema = false
)
@TypeConverters(UuidConverters::class)
abstract class MediaDatabase : RoomDatabase() {
abstract fun movieDao(): MovieDao
abstract fun seriesDao(): SeriesDao
abstract fun seasonDao(): SeasonDao
abstract fun episodeDao(): EpisodeDao
abstract fun castMemberDao(): CastMemberDao
}

View File

@@ -0,0 +1,37 @@
package hu.bbara.purefin.data.local.room
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object MediaDatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): MediaDatabase =
Room.inMemoryDatabaseBuilder(context, MediaDatabase::class.java)
.fallbackToDestructiveMigration()
.build()
@Provides
fun provideMovieDao(db: MediaDatabase) = db.movieDao()
@Provides
fun provideSeriesDao(db: MediaDatabase) = db.seriesDao()
@Provides
fun provideSeasonDao(db: MediaDatabase) = db.seasonDao()
@Provides
fun provideEpisodeDao(db: MediaDatabase) = db.episodeDao()
@Provides
fun provideCastMemberDao(db: MediaDatabase) = db.castMemberDao()
}

View File

@@ -0,0 +1,22 @@
package hu.bbara.purefin.data.local.room
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(tableName = "movies")
data class MovieEntity(
@PrimaryKey val id: UUID,
val libraryId: UUID,
val title: String,
val progress: Double?,
val watched: Boolean,
val year: String,
val rating: String,
val runtime: String,
val format: String,
val synopsis: String,
val heroImageUrl: String,
val audioTrack: String,
val subtitles: String
)

View File

@@ -0,0 +1,272 @@
package hu.bbara.purefin.data.local.room
import androidx.room.withTransaction
import hu.bbara.purefin.data.local.room.dao.CastMemberDao
import hu.bbara.purefin.data.local.room.dao.EpisodeDao
import hu.bbara.purefin.data.local.room.dao.MovieDao
import hu.bbara.purefin.data.local.room.dao.SeasonDao
import hu.bbara.purefin.data.local.room.dao.SeriesDao
import hu.bbara.purefin.data.model.CastMember
import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.data.model.Movie
import hu.bbara.purefin.data.model.Season
import hu.bbara.purefin.data.model.Series
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RoomMediaLocalDataSource @Inject constructor(
private val database: MediaDatabase,
private val movieDao: MovieDao,
private val seriesDao: SeriesDao,
private val seasonDao: SeasonDao,
private val episodeDao: EpisodeDao,
private val castMemberDao: CastMemberDao
) {
suspend fun saveMovies(movies: List<Movie>) {
database.withTransaction {
movieDao.upsertAll(movies.map { it.toEntity() })
}
}
suspend fun saveSeries(seriesList: List<Series>) {
database.withTransaction {
seriesDao.upsertAll(seriesList.map { it.toEntity() })
}
}
suspend fun saveSeriesContent(series: Series) {
database.withTransaction {
// First ensure the series exists before adding seasons/episodes/cast
seriesDao.upsert(series.toEntity())
episodeDao.deleteBySeriesId(series.id)
seasonDao.deleteBySeriesId(series.id)
series.seasons.forEach { season ->
seasonDao.upsert(season.toEntity())
season.episodes.forEach { episode ->
episodeDao.upsert(episode.toEntity())
}
}
}
}
suspend fun saveEpisode(episode: Episode) {
database.withTransaction {
seriesDao.getById(episode.seriesId)
?: throw RuntimeException("Cannot add episode without series. Episode: $episode")
episodeDao.upsert(episode.toEntity())
}
}
suspend fun getMovies(): List<Movie> {
val movies = movieDao.getAll()
return movies.map { entity ->
val cast = castMemberDao.getByMovieId(entity.id).map { it.toDomain() }
entity.toDomain(cast)
}
}
suspend fun getMovie(id: UUID): Movie? {
val entity = movieDao.getById(id) ?: return null
val cast = castMemberDao.getByMovieId(id).map { it.toDomain() }
return entity.toDomain(cast)
}
suspend fun getSeries(): List<Series> {
return seriesDao.getAll().mapNotNull { entity -> getSeriesInternal(entity.id, includeContent = false) }
}
suspend fun getSeriesBasic(id: UUID): Series? = getSeriesInternal(id, includeContent = false)
suspend fun getSeriesWithContent(id: UUID): Series? = getSeriesInternal(id, includeContent = true)
private suspend fun getSeriesInternal(id: UUID, includeContent: Boolean): Series? {
val entity = seriesDao.getById(id) ?: return null
val cast = castMemberDao.getBySeriesId(id).map { it.toDomain() }
val seasons = if (includeContent) {
seasonDao.getBySeriesId(id).map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(episodeCast)
}
seasonEntity.toDomain(episodes)
}
} else emptyList()
return entity.toDomain(seasons, cast)
}
suspend fun getSeason(seriesId: UUID, seasonId: UUID): Season? {
val seasonEntity = seasonDao.getById(seasonId) ?: return null
val episodes = episodeDao.getBySeasonId(seasonId).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(episodeCast)
}
return seasonEntity.toDomain(episodes)
}
suspend fun getSeasons(seriesId: UUID): List<Season> {
return seasonDao.getBySeriesId(seriesId).map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
val episodeCast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(episodeCast)
}
seasonEntity.toDomain(episodes)
}
}
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? {
val episodeEntity = episodeDao.getById(episodeId) ?: return null
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() }
return episodeEntity.toDomain(cast)
}
suspend fun getEpisodeById(episodeId: UUID): Episode? {
val episodeEntity = episodeDao.getById(episodeId) ?: return null
val cast = castMemberDao.getByEpisodeId(episodeId).map { it.toDomain() }
return episodeEntity.toDomain(cast)
}
suspend fun getEpisodesBySeries(seriesId: UUID): List<Episode> {
return episodeDao.getBySeriesId(seriesId).map { episodeEntity ->
val cast = castMemberDao.getByEpisodeId(episodeEntity.id).map { it.toDomain() }
episodeEntity.toDomain(cast)
}
}
private fun Movie.toEntity() = MovieEntity(
id = id,
libraryId = libraryId,
title = title,
progress = progress,
watched = watched,
year = year,
rating = rating,
runtime = runtime,
format = format,
synopsis = synopsis,
heroImageUrl = heroImageUrl,
audioTrack = audioTrack,
subtitles = subtitles
)
private fun Series.toEntity() = SeriesEntity(
id = id,
libraryId = libraryId,
name = name,
synopsis = synopsis,
year = year,
heroImageUrl = heroImageUrl,
seasonCount = seasonCount
)
private fun Season.toEntity() = SeasonEntity(
id = id,
seriesId = seriesId,
name = name,
index = index,
episodeCount = episodeCount
)
private fun Episode.toEntity() = EpisodeEntity(
id = id,
seriesId = seriesId,
seasonId = seasonId,
index = index,
title = title,
synopsis = synopsis,
releaseDate = releaseDate,
rating = rating,
runtime = runtime,
progress = progress,
watched = watched,
format = format,
heroImageUrl = heroImageUrl
)
private fun MovieEntity.toDomain(cast: List<CastMember>) = Movie(
id = id,
libraryId = libraryId,
title = title,
progress = progress,
watched = watched,
year = year,
rating = rating,
runtime = runtime,
format = format,
synopsis = synopsis,
heroImageUrl = heroImageUrl,
audioTrack = audioTrack,
subtitles = subtitles,
cast = cast
)
private fun SeriesEntity.toDomain(seasons: List<Season>, cast: List<CastMember>) = Series(
id = id,
libraryId = libraryId,
name = name,
synopsis = synopsis,
year = year,
heroImageUrl = heroImageUrl,
seasonCount = seasonCount,
seasons = seasons,
cast = cast
)
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
id = id,
seriesId = seriesId,
name = name,
index = index,
episodeCount = episodeCount,
episodes = episodes
)
private fun EpisodeEntity.toDomain(cast: List<CastMember>) = Episode(
id = id,
seriesId = seriesId,
seasonId = seasonId ?: seriesId, // fallback to series when season is absent
index = index,
title = title,
synopsis = synopsis,
releaseDate = releaseDate,
rating = rating,
runtime = runtime,
progress = progress,
watched = watched,
format = format,
heroImageUrl = heroImageUrl,
cast = cast
)
private fun CastMember.toMovieEntity(movieId: UUID) = CastMemberEntity(
name = name,
role = role,
imageUrl = imageUrl,
movieId = movieId
)
private fun CastMember.toSeriesEntity(seriesId: UUID) = CastMemberEntity(
name = name,
role = role,
imageUrl = imageUrl,
seriesId = seriesId
)
private fun CastMember.toEpisodeEntity(episodeId: UUID) = CastMemberEntity(
name = name,
role = role,
imageUrl = imageUrl,
episodeId = episodeId
)
private fun CastMemberEntity.toDomain() = CastMember(
name = name,
role = role,
imageUrl = imageUrl
)
}

View File

@@ -0,0 +1,27 @@
package hu.bbara.purefin.data.local.room
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(
tableName = "seasons",
foreignKeys = [
ForeignKey(
entity = SeriesEntity::class,
parentColumns = ["id"],
childColumns = ["seriesId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("seriesId")]
)
data class SeasonEntity(
@PrimaryKey val id: UUID,
val seriesId: UUID,
val name: String,
val index: Int,
val episodeCount: Int
)

View File

@@ -0,0 +1,16 @@
package hu.bbara.purefin.data.local.room
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(tableName = "series")
data class SeriesEntity(
@PrimaryKey val id: UUID,
val libraryId: UUID,
val name: String,
val synopsis: String,
val year: String,
val heroImageUrl: String,
val seasonCount: Int
)

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.data.local.room
import androidx.room.TypeConverter
import java.util.UUID
/**
* Stores UUIDs as strings for Room in-memory database.
*/
class UuidConverters {
@TypeConverter
fun fromString(value: String?): UUID? = value?.let(UUID::fromString)
@TypeConverter
fun uuidToString(uuid: UUID?): String? = uuid?.toString()
}

View File

@@ -0,0 +1,31 @@
package hu.bbara.purefin.data.local.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.local.room.CastMemberEntity
import java.util.UUID
@Dao
interface CastMemberDao {
@Upsert
suspend fun upsertAll(cast: List<CastMemberEntity>)
@Query("SELECT * FROM cast_members WHERE movieId = :movieId")
suspend fun getByMovieId(movieId: UUID): List<CastMemberEntity>
@Query("SELECT * FROM cast_members WHERE seriesId = :seriesId")
suspend fun getBySeriesId(seriesId: UUID): List<CastMemberEntity>
@Query("SELECT * FROM cast_members WHERE episodeId = :episodeId")
suspend fun getByEpisodeId(episodeId: UUID): List<CastMemberEntity>
@Query("DELETE FROM cast_members WHERE movieId = :movieId")
suspend fun deleteByMovieId(movieId: UUID)
@Query("DELETE FROM cast_members WHERE seriesId = :seriesId")
suspend fun deleteBySeriesId(seriesId: UUID)
@Query("DELETE FROM cast_members WHERE episodeId = :episodeId")
suspend fun deleteByEpisodeId(episodeId: UUID)
}

View File

@@ -0,0 +1,31 @@
package hu.bbara.purefin.data.local.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.local.room.EpisodeEntity
import java.util.UUID
@Dao
interface EpisodeDao {
@Upsert
suspend fun upsert(episode: EpisodeEntity)
@Upsert
suspend fun upsertAll(episodes: List<EpisodeEntity>)
@Query("SELECT * FROM episodes WHERE seriesId = :seriesId")
suspend fun getBySeriesId(seriesId: UUID): List<EpisodeEntity>
@Query("SELECT * FROM episodes WHERE seasonId = :seasonId")
suspend fun getBySeasonId(seasonId: UUID): List<EpisodeEntity>
@Query("SELECT * FROM episodes WHERE id = :id")
suspend fun getById(id: UUID): EpisodeEntity?
@Query("DELETE FROM episodes WHERE seriesId = :seriesId")
suspend fun deleteBySeriesId(seriesId: UUID)
@Query("DELETE FROM episodes WHERE seasonId = :seasonId")
suspend fun deleteBySeasonId(seasonId: UUID)
}

View File

@@ -0,0 +1,25 @@
package hu.bbara.purefin.data.local.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.local.room.MovieEntity
import java.util.UUID
@Dao
interface MovieDao {
@Upsert
suspend fun upsert(movie: MovieEntity)
@Upsert
suspend fun upsertAll(movies: List<MovieEntity>)
@Query("SELECT * FROM movies")
suspend fun getAll(): List<MovieEntity>
@Query("SELECT * FROM movies WHERE id = :id")
suspend fun getById(id: UUID): MovieEntity?
@Query("DELETE FROM movies")
suspend fun clear()
}

View File

@@ -0,0 +1,25 @@
package hu.bbara.purefin.data.local.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.local.room.SeasonEntity
import java.util.UUID
@Dao
interface SeasonDao {
@Upsert
suspend fun upsert(season: SeasonEntity)
@Upsert
suspend fun upsertAll(seasons: List<SeasonEntity>)
@Query("SELECT * FROM seasons WHERE seriesId = :seriesId")
suspend fun getBySeriesId(seriesId: UUID): List<SeasonEntity>
@Query("SELECT * FROM seasons WHERE id = :id")
suspend fun getById(id: UUID): SeasonEntity?
@Query("DELETE FROM seasons WHERE seriesId = :seriesId")
suspend fun deleteBySeriesId(seriesId: UUID)
}

View File

@@ -0,0 +1,25 @@
package hu.bbara.purefin.data.local.room.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import hu.bbara.purefin.data.local.room.SeriesEntity
import java.util.UUID
@Dao
interface SeriesDao {
@Upsert
suspend fun upsert(series: SeriesEntity)
@Upsert
suspend fun upsertAll(series: List<SeriesEntity>)
@Query("SELECT * FROM series")
suspend fun getAll(): List<SeriesEntity>
@Query("SELECT * FROM series WHERE id = :id")
suspend fun getById(id: UUID): SeriesEntity?
@Query("DELETE FROM series")
suspend fun clear()
}

View File

@@ -0,0 +1,18 @@
package hu.bbara.purefin.data.model
import org.jellyfin.sdk.model.api.CollectionType
import java.util.UUID
data class Library(
val id: UUID,
val name: String,
val type: CollectionType,
val series: List<Series>? = null,
val movies: List<Movie>? = null,
) {
init {
require(series != null || movies != null) { "Either series or movie must be provided" }
require(series == null || movies == null) { "Only one of series or movie can be provided" }
require(type == CollectionType.TVSHOWS || type == CollectionType.MOVIES) { "Invalid type: $type" }
}
}

View File

@@ -0,0 +1,13 @@
package hu.bbara.purefin.data.model
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
sealed class Media(
val id: UUID,
val type: BaseItemKind
) {
class MovieMedia(val movieId: UUID) : Media(movieId, BaseItemKind.MOVIE)
class SeriesMedia(val seriesId: UUID) : Media(seriesId, BaseItemKind.SERIES)
class EpisodeMedia(val episodeId: UUID, val seriesId: UUID) : Media(episodeId, BaseItemKind.EPISODE)
}

View File

@@ -0,0 +1,20 @@
package hu.bbara.purefin.data.model
import java.util.UUID
data class Movie(
val id: UUID,
val libraryId: UUID,
val title: String,
val progress: Double?,
val watched: Boolean,
val year: String,
val rating: String,
val runtime: String,
val format: String,
val synopsis: String,
val heroImageUrl: String,
val audioTrack: String,
val subtitles: String,
val cast: List<CastMember>
)

View File

@@ -4,6 +4,7 @@ import java.util.UUID
data class Series(
val id: UUID,
val libraryId: UUID,
val name: String,
val synopsis: String,
val year: String,

View File

@@ -0,0 +1,15 @@
package hu.bbara.purefin.navigation
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
data class EpisodeDto(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
@Serializable(with = UUIDSerializer::class)
val seasonId: UUID,
@Serializable(with = UUIDSerializer::class)
val seriesId: UUID,
)

View File

@@ -1,13 +1,11 @@
package hu.bbara.purefin.navigation
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
data class ItemDto (
data class MovieDto(
@Serializable(with = UUIDSerializer::class)
val id: UUID,
val type : BaseItemKind
)
val id: UUID
)

View File

@@ -8,13 +8,13 @@ sealed interface Route : NavKey {
data object Home: Route
@Serializable
data class MovieRoute(val item : ItemDto) : Route
data class MovieRoute(val item : MovieDto) : Route
@Serializable
data class SeriesRoute(val item : ItemDto) : Route
data class SeriesRoute(val item : SeriesDto) : Route
@Serializable
data class EpisodeRoute(val item : ItemDto) : Route
data class EpisodeRoute(val item : EpisodeDto) : Route
@Serializable
data class LibraryRoute(val library : LibraryDto) : Route

View File

@@ -0,0 +1,11 @@
package hu.bbara.purefin.navigation
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
data class SeriesDto(
@Serializable(with = UUIDSerializer::class)
val id: UUID
)

View File

@@ -19,6 +19,7 @@ foundation = "1.10.1"
coil = "3.3.0"
media3 = "1.9.0"
nav3Core = "1.0.0"
room = "2.6.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -54,6 +55,8 @@ medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media
medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"}
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
[plugins]