From fa76517e12fcf1e5d23329ad25b499f1a7619835 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sat, 31 Jan 2026 21:30:12 +0100 Subject: [PATCH] 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. --- app/build.gradle.kts | 2 + .../app/content/episode/EpisodeComponents.kt | 20 +- .../app/content/episode/EpisodeScreen.kt | 24 +- .../content/episode/EpisodeScreenViewModel.kt | 116 +----- .../app/content/movie/MovieComponents.kt | 10 +- .../purefin/app/content/movie/MovieScreen.kt | 4 +- .../app/content/series/SeriesComponents.kt | 55 +-- .../app/content/series/SeriesScreen.kt | 33 +- .../app/content/series/SeriesViewModel.kt | 103 +---- .../purefin/app/home/HomePageViewModel.kt | 189 +++++---- .../bbara/purefin/app/home/ui/HomeModels.kt | 62 ++- .../bbara/purefin/app/home/ui/HomeSections.kt | 27 +- .../purefin/app/library/LibraryViewModel.kt | 48 ++- .../bbara/purefin/client/JellyfinApiClient.kt | 24 +- .../common/ui/MediaDetailComponents.kt | 9 +- .../purefin/data/InMemoryMediaRepository.kt | 391 +++++++++++------- .../hu/bbara/purefin/data/MediaRepository.kt | 24 +- .../purefin/data/MediaRepositoryState.kt | 7 + .../data/local/room/CastMemberEntity.kt | 20 + .../purefin/data/local/room/EpisodeEntity.kt | 35 ++ .../purefin/data/local/room/MediaDatabase.kt | 30 ++ .../data/local/room/MediaDatabaseModule.kt | 37 ++ .../purefin/data/local/room/MovieEntity.kt | 22 + .../local/room/RoomMediaLocalDataSource.kt | 272 ++++++++++++ .../purefin/data/local/room/SeasonEntity.kt | 27 ++ .../purefin/data/local/room/SeriesEntity.kt | 16 + .../purefin/data/local/room/UuidConverters.kt | 15 + .../data/local/room/dao/CastMemberDao.kt | 31 ++ .../purefin/data/local/room/dao/EpisodeDao.kt | 31 ++ .../purefin/data/local/room/dao/MovieDao.kt | 25 ++ .../purefin/data/local/room/dao/SeasonDao.kt | 25 ++ .../purefin/data/local/room/dao/SeriesDao.kt | 25 ++ .../hu/bbara/purefin/data/model/Library.kt | 18 + .../java/hu/bbara/purefin/data/model/Media.kt | 13 + .../java/hu/bbara/purefin/data/model/Movie.kt | 20 + .../hu/bbara/purefin/data/model/Series.kt | 1 + .../hu/bbara/purefin/navigation/EpisodeDto.kt | 15 + .../navigation/{ItemDto.kt => MovieDto.kt} | 10 +- .../java/hu/bbara/purefin/navigation/Route.kt | 6 +- .../hu/bbara/purefin/navigation/SeriesDto.kt | 11 + gradle/libs.versions.toml | 3 + 41 files changed, 1289 insertions(+), 567 deletions(-) create mode 100644 app/src/main/java/hu/bbara/purefin/data/MediaRepositoryState.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/CastMemberEntity.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/EpisodeEntity.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabase.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabaseModule.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/MovieEntity.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/SeasonEntity.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/SeriesEntity.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/UuidConverters.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/dao/CastMemberDao.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeasonDao.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/model/Library.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/model/Media.kt create mode 100644 app/src/main/java/hu/bbara/purefin/data/model/Movie.kt create mode 100644 app/src/main/java/hu/bbara/purefin/navigation/EpisodeDto.kt rename app/src/main/java/hu/bbara/purefin/navigation/{ItemDto.kt => MovieDto.kt} (56%) create mode 100644 app/src/main/java/hu/bbara/purefin/navigation/SeriesDto.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 833a7b2..ea3e3e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt index 5431234..9a6d561 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt @@ -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 -) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt index 1ac1d35..e52b68d 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -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 = {} - ) -} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt index 044c03f..b4490ea 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt @@ -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(null) + private val _episode = MutableStateFlow(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" } } diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt index 304f0ee..9168ee9 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt @@ -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 -) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt index 7168754..8d87ea4 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt @@ -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) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt index 3f70287..e0dc5a9 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt @@ -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, - selectedSeason: SeriesSeasonUiModel?, + seasons: List, + 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, modifier: Modifier = Modifier) { +internal fun EpisodeCarousel(episodes: List, modifier: Modifier = Modifier) { val listState = rememberLazyListState() LaunchedEffect(episodes) { @@ -196,14 +199,18 @@ internal fun EpisodeCarousel(episodes: List, 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, modifier: Modifier = Modifier) { +internal fun CastRow(cast: List, 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 -) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt index 9cca580..bba6214 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -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(getDefaultSeason()) } + val selectedSeason = remember { mutableStateOf(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 = {} - ) -} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt index e2fbfb0..e385e00 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesViewModel.kt @@ -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(null) + private val _series = MutableStateFlow(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, - episodesItemResult: Map> - ): 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 - ) - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt index 712a411..95e446f 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt @@ -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>(emptyList()) - val continueWatching = _continueWatching.asStateFlow() - private val _libraries = MutableStateFlow>(emptyList()) val libraries = _libraries.asStateFlow() - private val _libraryItems = MutableStateFlow>>(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>>(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 = 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 = 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() } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt index ecdfe73..142c6ed 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt @@ -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 -) + 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, diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt index 9fa5dae..47035c4 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt @@ -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 + ) + } ) } } diff --git a/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt index 0aa9827..53b7072 100644 --- a/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/library/LibraryViewModel.kt @@ -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>(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 ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index 035848d..7b18ec0 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -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 = 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 { + suspend fun getLibraryContent(libraryId: UUID): List { 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()) diff --git a/app/src/main/java/hu/bbara/purefin/common/ui/MediaDetailComponents.kt b/app/src/main/java/hu/bbara/purefin/common/ui/MediaDetailComponents.kt index f7c4798..38f85f5 100644 --- a/app/src/main/java/hu/bbara/purefin/common/ui/MediaDetailComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/common/ui/MediaDetailComponents.kt @@ -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, + cast: List, modifier: Modifier = Modifier, cardWidth: Dp = 96.dp, nameSize: TextUnit = 12.sp, diff --git a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt index c454b2b..da9b059 100644 --- a/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/InMemoryMediaRepository.kt @@ -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 = LruCache(100) + private val ready = CompletableDeferred() - 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 = MutableStateFlow(MediaRepositoryState.Loading) + override val state: StateFlow = _state + + private val _movies : MutableStateFlow> = MutableStateFlow(emptyMap()) + override val movies: StateFlow> = _movies + + private val _series : MutableStateFlow> = MutableStateFlow(emptyMap()) + override val series: StateFlow> = _series + + private val _continueWatching: MutableStateFlow> = MutableStateFlow(emptyList()) + val continueWatching: StateFlow> = _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 { - 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 { - 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 { - val cachedSeasons = fetchAndUpdateSeasonsIfMissing(seriesId) - if (cachedSeasons.all { hasContent(it) }) { - return cachedSeasons.flatMap { it.episodes } - } - - return cachedSeasons.flatMap { season -> - getEpisodes(seriesId, season.id) - } - } - - private suspend fun fetchAndUpdateSeriesIfMissing(seriesId: UUID): Series { - val cachedSeries = seriesCache[seriesId] - if (cachedSeries == null) { - val seriesItemInfo = jellyfinApiClient.getItemInfo(seriesId) - ?: throw RuntimeException("Series not found") - val series = seriesItemInfo.toSeries(serverUrl()) - seriesCache.put(seriesId, series) - } - return seriesCache[seriesId]!! - } - - private suspend fun fetchAndUpdateSeasonIfMissing(seriesId: UUID, seasonId: UUID): Season { - val cachedSeries = fetchAndUpdateSeriesIfMissing(seriesId) - cachedSeries.seasons.find { it.id == seasonId }?.let { - return it - } - val seasonsItemInfo = jellyfinApiClient.getSeasons(seriesId) - val seasons = seasonsItemInfo.map { it.toSeason(serverUrl()) } - val series = cachedSeries.copy( - seasons = seasons - ) - seriesCache.put(seriesId, series) - return seasons.find { it.id == seasonId }!! - } - - private suspend fun fetchAndUpdateSeasonsIfMissing(seriesId: UUID): List { - val cachedSeries = fetchAndUpdateSeriesIfMissing(seriesId) - if (cachedSeries.seasons.size == cachedSeries.seasonCount) { - return cachedSeries.seasons - } - - val seasonsItemInfo = jellyfinApiClient.getSeasons(seriesId) - val seasons = seasonsItemInfo.map { it.toSeason(serverUrl()) } - val series = cachedSeries.copy( - seasons = seasons - ) - seriesCache.put(seriesId, series) - return seasons - - } - - private fun hasContent(series: Series): Boolean { - if (series.seasons.size != series.seasonCount) { - return false - } - for (season in series.seasons) { - if (hasContent(season).not()) { - return false - } - } - return true - } - - private fun hasContent(season: Season) : Boolean { - return season.episodes.size == season.episodeCount + 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" } } -} \ No newline at end of file +} diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt index ccf465d..53bb1d0 100644 --- a/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepository.kt @@ -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> + val series: StateFlow> + val state: StateFlow - suspend fun getSeason(seriesId: UUID, seasonId: UUID, includeContent: Boolean) : Season - - suspend fun getSeasons(seriesId: UUID, includeContent: Boolean) : List - - suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode - - suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List + 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 + suspend fun getSeason(seriesId: UUID, seasonId: UUID) : Season suspend fun getEpisodes(seriesId: UUID) : List + suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List + suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode + suspend fun getEpisode(seriesId: UUID, episodeId: UUID) : Episode -} \ No newline at end of file +} diff --git a/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryState.kt b/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryState.kt new file mode 100644 index 0000000..db6132d --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/MediaRepositoryState.kt @@ -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 +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/CastMemberEntity.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/CastMemberEntity.kt new file mode 100644 index 0000000..7463def --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/CastMemberEntity.kt @@ -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 +) diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/EpisodeEntity.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/EpisodeEntity.kt new file mode 100644 index 0000000..0b57a1e --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/EpisodeEntity.kt @@ -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 +) diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabase.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabase.kt new file mode 100644 index 0000000..a83e9f5 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabase.kt @@ -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 +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabaseModule.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabaseModule.kt new file mode 100644 index 0000000..a4b1766 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/MediaDatabaseModule.kt @@ -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() +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/MovieEntity.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/MovieEntity.kt new file mode 100644 index 0000000..9acb008 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/MovieEntity.kt @@ -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 +) diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt new file mode 100644 index 0000000..b7ad287 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/RoomMediaLocalDataSource.kt @@ -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) { + database.withTransaction { + movieDao.upsertAll(movies.map { it.toEntity() }) + } + } + + suspend fun saveSeries(seriesList: List) { + 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 { + 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 { + 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 { + 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 { + 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) = 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, cast: List) = 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) = Season( + id = id, + seriesId = seriesId, + name = name, + index = index, + episodeCount = episodeCount, + episodes = episodes + ) + + private fun EpisodeEntity.toDomain(cast: List) = 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 + ) +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/SeasonEntity.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/SeasonEntity.kt new file mode 100644 index 0000000..8b6507e --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/SeasonEntity.kt @@ -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 +) diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/SeriesEntity.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/SeriesEntity.kt new file mode 100644 index 0000000..862d47a --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/SeriesEntity.kt @@ -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 +) diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/UuidConverters.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/UuidConverters.kt new file mode 100644 index 0000000..aed88e5 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/UuidConverters.kt @@ -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() +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/CastMemberDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/CastMemberDao.kt new file mode 100644 index 0000000..71bdd96 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/CastMemberDao.kt @@ -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) + + @Query("SELECT * FROM cast_members WHERE movieId = :movieId") + suspend fun getByMovieId(movieId: UUID): List + + @Query("SELECT * FROM cast_members WHERE seriesId = :seriesId") + suspend fun getBySeriesId(seriesId: UUID): List + + @Query("SELECT * FROM cast_members WHERE episodeId = :episodeId") + suspend fun getByEpisodeId(episodeId: UUID): List + + @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) +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt new file mode 100644 index 0000000..37fe87e --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/EpisodeDao.kt @@ -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) + + @Query("SELECT * FROM episodes WHERE seriesId = :seriesId") + suspend fun getBySeriesId(seriesId: UUID): List + + @Query("SELECT * FROM episodes WHERE seasonId = :seasonId") + suspend fun getBySeasonId(seasonId: UUID): List + + @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) +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt new file mode 100644 index 0000000..b54a08c --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/MovieDao.kt @@ -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) + + @Query("SELECT * FROM movies") + suspend fun getAll(): List + + @Query("SELECT * FROM movies WHERE id = :id") + suspend fun getById(id: UUID): MovieEntity? + + @Query("DELETE FROM movies") + suspend fun clear() +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeasonDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeasonDao.kt new file mode 100644 index 0000000..64fc41a --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeasonDao.kt @@ -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) + + @Query("SELECT * FROM seasons WHERE seriesId = :seriesId") + suspend fun getBySeriesId(seriesId: UUID): List + + @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) +} diff --git a/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt new file mode 100644 index 0000000..19f828b --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/local/room/dao/SeriesDao.kt @@ -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) + + @Query("SELECT * FROM series") + suspend fun getAll(): List + + @Query("SELECT * FROM series WHERE id = :id") + suspend fun getById(id: UUID): SeriesEntity? + + @Query("DELETE FROM series") + suspend fun clear() +} diff --git a/app/src/main/java/hu/bbara/purefin/data/model/Library.kt b/app/src/main/java/hu/bbara/purefin/data/model/Library.kt new file mode 100644 index 0000000..399a359 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/model/Library.kt @@ -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? = null, + val movies: List? = 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" } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/data/model/Media.kt b/app/src/main/java/hu/bbara/purefin/data/model/Media.kt new file mode 100644 index 0000000..623d00c --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/model/Media.kt @@ -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) +} diff --git a/app/src/main/java/hu/bbara/purefin/data/model/Movie.kt b/app/src/main/java/hu/bbara/purefin/data/model/Movie.kt new file mode 100644 index 0000000..e62cd6c --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/data/model/Movie.kt @@ -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 +) diff --git a/app/src/main/java/hu/bbara/purefin/data/model/Series.kt b/app/src/main/java/hu/bbara/purefin/data/model/Series.kt index 7f80576..81eba57 100644 --- a/app/src/main/java/hu/bbara/purefin/data/model/Series.kt +++ b/app/src/main/java/hu/bbara/purefin/data/model/Series.kt @@ -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, diff --git a/app/src/main/java/hu/bbara/purefin/navigation/EpisodeDto.kt b/app/src/main/java/hu/bbara/purefin/navigation/EpisodeDto.kt new file mode 100644 index 0000000..720fa84 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/navigation/EpisodeDto.kt @@ -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, +) diff --git a/app/src/main/java/hu/bbara/purefin/navigation/ItemDto.kt b/app/src/main/java/hu/bbara/purefin/navigation/MovieDto.kt similarity index 56% rename from app/src/main/java/hu/bbara/purefin/navigation/ItemDto.kt rename to app/src/main/java/hu/bbara/purefin/navigation/MovieDto.kt index 289c80f..58c782f 100644 --- a/app/src/main/java/hu/bbara/purefin/navigation/ItemDto.kt +++ b/app/src/main/java/hu/bbara/purefin/navigation/MovieDto.kt @@ -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 -) \ No newline at end of file + val id: UUID +) diff --git a/app/src/main/java/hu/bbara/purefin/navigation/Route.kt b/app/src/main/java/hu/bbara/purefin/navigation/Route.kt index ec41a49..2403582 100644 --- a/app/src/main/java/hu/bbara/purefin/navigation/Route.kt +++ b/app/src/main/java/hu/bbara/purefin/navigation/Route.kt @@ -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 diff --git a/app/src/main/java/hu/bbara/purefin/navigation/SeriesDto.kt b/app/src/main/java/hu/bbara/purefin/navigation/SeriesDto.kt new file mode 100644 index 0000000..a750f67 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/navigation/SeriesDto.kt @@ -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 +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d6e560..2b9f8f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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]