implement Room local data source and refactor media repository

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

View File

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

View File

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

View File

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

View File

@@ -3,133 +3,39 @@ package hu.bbara.purefin.app.content.episode
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.data.InMemoryMediaRepository
import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.data.model.Episode
import hu.bbara.purefin.navigation.ItemDto
import hu.bbara.purefin.navigation.NavigationManager 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.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.UUID 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
class EpisodeScreenViewModel @Inject constructor( class EpisodeScreenViewModel @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient, private val mediaRepository: InMemoryMediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val userSessionRepository: UserSessionRepository
): ViewModel() { ): ViewModel() {
private val _episode = MutableStateFlow<EpisodeUiModel?>(null) private val _episode = MutableStateFlow<Episode?>(null)
val episode = _episode.asStateFlow() val episode = _episode.asStateFlow()
fun onSeriesSelected(seriesId: String) { init {
viewModelScope.launch { viewModelScope.launch { mediaRepository.ensureReady() }
navigationManager.navigate(Route.SeriesRoute(ItemDto(id = UUID.fromString(seriesId), type = BaseItemKind.SERIES)))
}
} }
fun onBack() { fun onBack() {
navigationManager.pop() navigationManager.pop()
} }
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
fun onGoHome() {
navigationManager.replaceAll(Route.Home)
}
fun selectNextUpEpisodeForSeries(seriesId: UUID) {
viewModelScope.launch { viewModelScope.launch {
val episode = jellyfinApiClient.getNextUpEpisode(seriesId) _episode.value = mediaRepository.getEpisode(
if (episode == null) { seriesId = seriesId,
_episode.value = null seasonId = seasonId,
return@launch episodeId = episodeId,
}
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
) )
} ?: ""
val cast = people.orEmpty().map { it.toCastMember() }
return EpisodeUiModel(
id = id,
title = name ?: "Unknown title",
seasonNumber = parentIndexNumber!!,
episodeNumber = indexNumber!!,
releaseDate = releaseDate,
rating = rating,
runtime = runtime,
format = format,
synopsis = synopsis,
heroImageUrl = heroImageUrl,
audioTrack = "Default",
subtitles = "Unknown",
cast = cast
)
}
private fun BaseItemPerson.toCastMember(): CastMember {
return CastMember(
name = name ?: "Unknown",
role = role ?: "",
imageUrl = null
)
}
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
if (date == null) {
return fallbackYear?.toString() ?: ""
}
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
return date.toLocalDate().format(formatter)
}
private fun formatRuntime(ticks: Long?): String {
if (ticks == null || ticks <= 0) return ""
val totalSeconds = ticks / 10_000_000
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) {
"${hours}h ${minutes}m"
} else {
"${minutes}m"
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,39 @@
package hu.bbara.purefin.app.home.ui package hu.bbara.purefin.app.home.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.UUID
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
data class ContinueWatchingItem( data class ContinueWatchingItem(
val id: UUID,
val type: BaseItemKind, val type: BaseItemKind,
val primaryText: String, val movie: Movie? = null,
val secondaryText: String, val episode: Episode? = null
val progress: Double, ) {
val colors: List<Color> 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( data class LibraryItem(
val id: UUID, val id: UUID,
@@ -23,11 +43,31 @@ data class LibraryItem(
) )
data class PosterItem( data class PosterItem(
val id: UUID,
val title: String,
val type: BaseItemKind, 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( data class HomeNavItem(
val id: UUID, val id: UUID,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
package hu.bbara.purefin.data.local.room
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(
tableName = "episodes",
foreignKeys = [
ForeignKey(
entity = SeriesEntity::class,
parentColumns = ["id"],
childColumns = ["seriesId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("seriesId"), Index("seasonId")]
)
data class EpisodeEntity(
@PrimaryKey val id: UUID,
val seriesId: UUID,
val seasonId: UUID?,
val index: Int,
val title: String,
val synopsis: String,
val releaseDate: String,
val rating: String,
val runtime: String,
val progress: Double?,
val watched: Boolean,
val format: String,
val heroImageUrl: String
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ foundation = "1.10.1"
coil = "3.3.0" coil = "3.3.0"
media3 = "1.9.0" media3 = "1.9.0"
nav3Core = "1.0.0" nav3Core = "1.0.0"
room = "2.6.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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"} 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-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", 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] [plugins]