mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,133 +3,39 @@ package hu.bbara.purefin.app.content.episode
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.navigation.ItemDto
|
||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EpisodeScreenViewModel @Inject constructor(
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val mediaRepository: InMemoryMediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val userSessionRepository: UserSessionRepository
|
||||
): ViewModel() {
|
||||
|
||||
private val _episode = MutableStateFlow<EpisodeUiModel?>(null)
|
||||
private val _episode = MutableStateFlow<Episode?>(null)
|
||||
val episode = _episode.asStateFlow()
|
||||
|
||||
fun onSeriesSelected(seriesId: String) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.SeriesRoute(ItemDto(id = UUID.fromString(seriesId), type = BaseItemKind.SERIES)))
|
||||
}
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
}
|
||||
|
||||
fun onBack() {
|
||||
navigationManager.pop()
|
||||
}
|
||||
|
||||
|
||||
fun onGoHome() {
|
||||
navigationManager.replaceAll(Route.Home)
|
||||
}
|
||||
|
||||
fun selectNextUpEpisodeForSeries(seriesId: UUID) {
|
||||
fun selectEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
|
||||
viewModelScope.launch {
|
||||
val episode = jellyfinApiClient.getNextUpEpisode(seriesId)
|
||||
if (episode == null) {
|
||||
_episode.value = null
|
||||
return@launch
|
||||
}
|
||||
selectEpisodeInternal(episode.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectEpisode(episodeId: UUID) {
|
||||
viewModelScope.launch {
|
||||
selectEpisodeInternal(episodeId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun selectEpisodeInternal(episodeId: UUID) {
|
||||
val episodeInfo = jellyfinApiClient.getItemInfo(episodeId)
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank {
|
||||
"https://jellyfin.bbara.hu"
|
||||
}
|
||||
_episode.value = episodeInfo!!.toUiModel(serverUrl)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toUiModel(serverUrl: String): EpisodeUiModel {
|
||||
val releaseDate = formatReleaseDate(premiereDate, productionYear)
|
||||
val rating = officialRating ?: "NR"
|
||||
val runtime = formatRuntime(runTimeTicks)
|
||||
val format = container?.uppercase() ?: "VIDEO"
|
||||
val synopsis = overview ?: "No synopsis available."
|
||||
val heroImageUrl = id?.let { itemId ->
|
||||
JellyfinImageHelper.toImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = itemId,
|
||||
type = ImageType.PRIMARY
|
||||
_episode.value = mediaRepository.getEpisode(
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
episodeId = episodeId,
|
||||
)
|
||||
} ?: ""
|
||||
val cast = people.orEmpty().map { it.toCastMember() }
|
||||
return EpisodeUiModel(
|
||||
id = id,
|
||||
title = name ?: "Unknown title",
|
||||
seasonNumber = parentIndexNumber!!,
|
||||
episodeNumber = indexNumber!!,
|
||||
releaseDate = releaseDate,
|
||||
rating = rating,
|
||||
runtime = runtime,
|
||||
format = format,
|
||||
synopsis = synopsis,
|
||||
heroImageUrl = heroImageUrl,
|
||||
audioTrack = "Default",
|
||||
subtitles = "Unknown",
|
||||
cast = cast
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemPerson.toCastMember(): CastMember {
|
||||
return CastMember(
|
||||
name = name ?: "Unknown",
|
||||
role = role ?: "",
|
||||
imageUrl = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
|
||||
if (date == null) {
|
||||
return fallbackYear?.toString() ?: "—"
|
||||
}
|
||||
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
|
||||
return date.toLocalDate().format(formatter)
|
||||
}
|
||||
|
||||
private fun formatRuntime(ticks: Long?): String {
|
||||
if (ticks == null || ticks <= 0) return "—"
|
||||
val totalSeconds = ticks / 10_000_000
|
||||
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
|
||||
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
|
||||
return if (hours > 0) {
|
||||
"${hours}h ${minutes}m"
|
||||
} else {
|
||||
"${minutes}m"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -46,12 +46,15 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.common.ui.MediaCastMember
|
||||
import hu.bbara.purefin.common.ui.MediaCastRow
|
||||
import hu.bbara.purefin.common.ui.MediaMetaChip
|
||||
import hu.bbara.purefin.common.ui.components.GhostIconButton
|
||||
import hu.bbara.purefin.common.ui.components.MediaActionButton
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.data.model.CastMember
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Season
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
|
||||
@Composable
|
||||
internal fun SeriesTopBar(
|
||||
@@ -79,21 +82,21 @@ internal fun SeriesTopBar(
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun SeriesMetaChips(series: SeriesUiModel) {
|
||||
internal fun SeriesMetaChips(series: Series) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MediaMetaChip(text = series.year)
|
||||
MediaMetaChip(text = series.rating)
|
||||
MediaMetaChip(text = series.seasons)
|
||||
MediaMetaChip(
|
||||
text = series.format,
|
||||
background = scheme.primary.copy(alpha = 0.2f),
|
||||
border = scheme.primary.copy(alpha = 0.3f),
|
||||
textColor = scheme.primary
|
||||
)
|
||||
// MediaMetaChip(text = series.rating)
|
||||
MediaMetaChip(text = "${series.seasonCount} Seasons")
|
||||
// MediaMetaChip(
|
||||
// text = series.,
|
||||
// background = scheme.primary.copy(alpha = 0.2f),
|
||||
// border = scheme.primary.copy(alpha = 0.3f),
|
||||
// textColor = scheme.primary
|
||||
// )
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,10 +121,10 @@ internal fun SeriesActionButtons(modifier: Modifier = Modifier) {
|
||||
|
||||
@Composable
|
||||
internal fun SeasonTabs(
|
||||
seasons: List<SeriesSeasonUiModel>,
|
||||
selectedSeason: SeriesSeasonUiModel?,
|
||||
seasons: List<Season>,
|
||||
selectedSeason: Season?,
|
||||
modifier: Modifier = Modifier,
|
||||
onSelect: (SeriesSeasonUiModel) -> Unit
|
||||
onSelect: (Season) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -170,7 +173,7 @@ private fun SeasonTab(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EpisodeCarousel(episodes: List<SeriesEpisodeUiModel>, modifier: Modifier = Modifier) {
|
||||
internal fun EpisodeCarousel(episodes: List<Episode>, modifier: Modifier = Modifier) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(episodes) {
|
||||
@@ -196,14 +199,18 @@ internal fun EpisodeCarousel(episodes: List<SeriesEpisodeUiModel>, modifier: Mod
|
||||
@Composable
|
||||
private fun EpisodeCard(
|
||||
viewModel: SeriesViewModel = hiltViewModel(),
|
||||
episode: SeriesEpisodeUiModel
|
||||
episode: Episode
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(260.dp)
|
||||
.clickable { viewModel.onSelectEpisode(episode.id) },
|
||||
.clickable { viewModel.onSelectEpisode(
|
||||
seriesId = episode.seriesId,
|
||||
seasonId = episode.seasonId,
|
||||
episodeId = episode.id
|
||||
) },
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(
|
||||
@@ -215,7 +222,7 @@ private fun EpisodeCard(
|
||||
.border(1.dp, scheme.outlineVariant, RoundedCornerShape(12.dp))
|
||||
) {
|
||||
PurefinAsyncImage(
|
||||
model = episode.imageUrl,
|
||||
model = episode.heroImageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
@@ -241,7 +248,7 @@ private fun EpisodeCard(
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = episode.duration,
|
||||
text = episode.runtime,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
@@ -260,7 +267,7 @@ private fun EpisodeCard(
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "S${episode.seasonNumber} • E${episode.episodeNumber}",
|
||||
text = "Episode ${episode.index}",
|
||||
color = mutedStrong,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
@@ -271,18 +278,12 @@ private fun EpisodeCard(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CastRow(cast: List<SeriesCastMemberUiModel>, modifier: Modifier = Modifier) {
|
||||
internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
|
||||
MediaCastRow(
|
||||
cast = cast.map { it.toMediaCastMember() },
|
||||
cast = cast,
|
||||
modifier = modifier,
|
||||
cardWidth = 84.dp,
|
||||
nameSize = 11.sp,
|
||||
roleSize = 10.sp
|
||||
)
|
||||
}
|
||||
|
||||
private fun SeriesCastMemberUiModel.toMediaCastMember() = MediaCastMember(
|
||||
name = name,
|
||||
role = role,
|
||||
imageUrl = imageUrl
|
||||
)
|
||||
|
||||
@@ -18,19 +18,19 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.app.content.ContentMockData
|
||||
import hu.bbara.purefin.common.ui.MediaSynopsis
|
||||
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
|
||||
import hu.bbara.purefin.common.ui.components.MediaHero
|
||||
import hu.bbara.purefin.navigation.ItemDto
|
||||
import hu.bbara.purefin.data.model.Season
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import hu.bbara.purefin.navigation.SeriesDto
|
||||
|
||||
@Composable
|
||||
fun SeriesScreen(
|
||||
series: ItemDto,
|
||||
series: SeriesDto,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SeriesViewModel = hiltViewModel()
|
||||
) {
|
||||
@@ -53,15 +53,15 @@ fun SeriesScreen(
|
||||
|
||||
@Composable
|
||||
private fun SeriesScreenInternal(
|
||||
series: SeriesUiModel,
|
||||
series: Series,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val textMutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
|
||||
fun getDefaultSeason() : SeriesSeasonUiModel {
|
||||
for (season in series.seasonTabs) {
|
||||
fun getDefaultSeason() : Season {
|
||||
for (season in series.seasons) {
|
||||
val firstUnwatchedEpisode = season.episodes.firstOrNull {
|
||||
it.watched.not()
|
||||
}
|
||||
@@ -69,9 +69,9 @@ private fun SeriesScreenInternal(
|
||||
return season
|
||||
}
|
||||
}
|
||||
return series.seasonTabs.first()
|
||||
return series.seasons.first()
|
||||
}
|
||||
val selectedSeason = remember { mutableStateOf<SeriesSeasonUiModel>(getDefaultSeason()) }
|
||||
val selectedSeason = remember { mutableStateOf<Season>(getDefaultSeason()) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
@@ -101,7 +101,7 @@ private fun SeriesScreenInternal(
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
Text(
|
||||
text = series.title,
|
||||
text = series.name,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -121,12 +121,12 @@ private fun SeriesScreenInternal(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
SeasonTabs(
|
||||
seasons = series.seasonTabs,
|
||||
seasons = series.seasons,
|
||||
selectedSeason = selectedSeason.value,
|
||||
onSelect = { selectedSeason.value = it }
|
||||
)
|
||||
EpisodeCarousel(
|
||||
episodes = selectedSeason.value.episodes
|
||||
episodes = selectedSeason.value.episodes,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
@@ -141,12 +141,3 @@ private fun SeriesScreenInternal(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SeriesScreenPreview() {
|
||||
SeriesScreenInternal(
|
||||
series = ContentMockData.series(),
|
||||
onBack = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,36 +3,39 @@ package hu.bbara.purefin.app.content.series
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.navigation.ItemDto
|
||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import hu.bbara.purefin.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SeriesViewModel @Inject constructor(
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val mediaRepository: InMemoryMediaRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val userSessionRepository: UserSessionRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _series = MutableStateFlow<SeriesUiModel?>(null)
|
||||
private val _series = MutableStateFlow<Series?>(null)
|
||||
val series = _series.asStateFlow()
|
||||
|
||||
fun onSelectEpisode(episodeId: String) {
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
}
|
||||
|
||||
fun onSelectEpisode(seriesId: UUID, seasonId:UUID, episodeId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.EpisodeRoute(ItemDto(id = UUID.fromString(episodeId), type = BaseItemKind.EPISODE)))
|
||||
navigationManager.navigate(Route.EpisodeRoute(
|
||||
EpisodeDto(
|
||||
id = episodeId,
|
||||
seasonId = seasonId,
|
||||
seriesId = seriesId
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,74 +50,10 @@ class SeriesViewModel @Inject constructor(
|
||||
|
||||
fun selectSeries(seriesId: UUID) {
|
||||
viewModelScope.launch {
|
||||
val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank {
|
||||
"https://jellyfin.bbara.hu"
|
||||
}
|
||||
val seriesItemResult = jellyfinApiClient.getItemInfo(mediaId = seriesId)
|
||||
val seasonsItemResult = jellyfinApiClient.getSeasons(seriesId)
|
||||
val episodesItemResult = seasonsItemResult.associate { season ->
|
||||
season.id to jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
|
||||
}
|
||||
val seriesUiModel = mapToSeriesUiModel(
|
||||
serverUrl,
|
||||
seriesItemResult,
|
||||
seasonsItemResult,
|
||||
episodesItemResult
|
||||
val series = mediaRepository.getSeriesWithContent(
|
||||
seriesId = seriesId
|
||||
)
|
||||
_series.value = seriesUiModel
|
||||
_series.value = series
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapToSeriesUiModel(
|
||||
serverUrl: String,
|
||||
seriesItemResult: BaseItemDto?,
|
||||
seasonsItemResult: List<BaseItemDto>,
|
||||
episodesItemResult: Map<UUID, List<BaseItemDto>>
|
||||
): SeriesUiModel {
|
||||
val seasonUiModels = seasonsItemResult.map { season ->
|
||||
val episodeItemResult = episodesItemResult[season.id] ?: emptyList()
|
||||
val episodeItemUiModels = episodeItemResult.map { episode ->
|
||||
SeriesEpisodeUiModel(
|
||||
id = episode.id.toString(),
|
||||
title = episode.name ?: "Unknown",
|
||||
seasonNumber = episode.parentIndexNumber!!,
|
||||
episodeNumber = episode.indexNumber!!,
|
||||
description = episode.overview ?: "",
|
||||
duration = "58m",
|
||||
imageUrl = JellyfinImageHelper.toImageUrl(url = serverUrl, itemId = episode.id, type = ImageType.PRIMARY),
|
||||
progress = episode.userData!!.playedPercentage,
|
||||
watched = episode.userData!!.played
|
||||
)
|
||||
}
|
||||
SeriesSeasonUiModel(
|
||||
name = season.name ?: "Unknown",
|
||||
episodes = episodeItemUiModels,
|
||||
unplayedCount = season.userData!!.unplayedItemCount
|
||||
)
|
||||
}
|
||||
return SeriesUiModel(
|
||||
title = seriesItemResult?.name ?: "Unknown",
|
||||
format = seriesItemResult?.container ?: "VIDEO",
|
||||
rating = seriesItemResult?.officialRating ?: "NR",
|
||||
year = seriesItemResult!!.productionYear?.toString() ?: seriesItemResult!!.premiereDate?.year?.toString().orEmpty(),
|
||||
seasons = "3 Seasons",
|
||||
synopsis = seriesItemResult.overview ?: "No synopsis available.",
|
||||
heroImageUrl = JellyfinImageHelper.toImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = seriesItemResult.id,
|
||||
type = ImageType.PRIMARY
|
||||
),
|
||||
seasonTabs = seasonUiModels,
|
||||
cast = seriesItemResult.people.orEmpty().map { it.toCastMember() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemPerson.toCastMember(): SeriesCastMemberUiModel {
|
||||
return SeriesCastMemberUiModel(
|
||||
name = name ?: "Unknown",
|
||||
role = role ?: "",
|
||||
imageUrl = null
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package hu.bbara.purefin.app.home
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -9,15 +8,21 @@ import hu.bbara.purefin.app.home.ui.HomeNavItem
|
||||
import hu.bbara.purefin.app.home.ui.LibraryItem
|
||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.navigation.ItemDto
|
||||
import hu.bbara.purefin.navigation.EpisodeDto
|
||||
import hu.bbara.purefin.navigation.LibraryDto
|
||||
import hu.bbara.purefin.navigation.MovieDto
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.navigation.SeriesDto
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -25,12 +30,11 @@ import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HomePageViewModel @Inject constructor(
|
||||
private val mediaRepository: InMemoryMediaRepository,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val jellyfinApiClient: JellyfinApiClient
|
||||
@@ -42,19 +46,44 @@ class HomePageViewModel @Inject constructor(
|
||||
initialValue = ""
|
||||
)
|
||||
|
||||
private val _continueWatching = MutableStateFlow<List<ContinueWatchingItem>>(emptyList())
|
||||
val continueWatching = _continueWatching.asStateFlow()
|
||||
|
||||
private val _libraries = MutableStateFlow<List<LibraryItem>>(emptyList())
|
||||
val libraries = _libraries.asStateFlow()
|
||||
|
||||
private val _libraryItems = MutableStateFlow<Map<UUID, List<PosterItem>>>(emptyMap())
|
||||
val libraryItems = _libraryItems.asStateFlow()
|
||||
val continueWatching = mediaRepository.continueWatching.map { list ->
|
||||
list.map {
|
||||
when ( it ) {
|
||||
is Media.MovieMedia -> {
|
||||
val movie = mediaRepository.getMovie(it.movieId)
|
||||
ContinueWatchingItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = movie
|
||||
)
|
||||
}
|
||||
is Media.EpisodeMedia -> {
|
||||
val episode = mediaRepository.getEpisode(
|
||||
seriesId = it.seriesId,
|
||||
episodeId = it.episodeId
|
||||
)
|
||||
ContinueWatchingItem(
|
||||
type = BaseItemKind.EPISODE,
|
||||
episode = episode
|
||||
)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $it")
|
||||
}
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
private val _latestLibraryContent = MutableStateFlow<Map<UUID, List<PosterItem>>>(emptyMap())
|
||||
val latestLibraryContent = _latestLibraryContent.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
loadHomePageData()
|
||||
}
|
||||
|
||||
@@ -64,19 +93,33 @@ class HomePageViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun onMovieSelected(movieId: String) {
|
||||
navigationManager.navigate(Route.MovieRoute(ItemDto(id = UUID.fromString(movieId), type = BaseItemKind.MOVIE)))
|
||||
fun onMovieSelected(movieId: UUID) {
|
||||
navigationManager.navigate(Route.MovieRoute(
|
||||
MovieDto(
|
||||
id = movieId,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fun onSeriesSelected(seriesId: String) {
|
||||
fun onSeriesSelected(seriesId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.SeriesRoute(ItemDto(id = UUID.fromString(seriesId), type = BaseItemKind.SERIES)))
|
||||
navigationManager.navigate(Route.SeriesRoute(
|
||||
SeriesDto(
|
||||
id = seriesId,
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun onEpisodeSelected(episodeId: String) {
|
||||
fun onEpisodeSelected(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.EpisodeRoute(ItemDto(id = UUID.fromString(episodeId), type = BaseItemKind.EPISODE)))
|
||||
navigationManager.navigate(Route.EpisodeRoute(
|
||||
EpisodeDto(
|
||||
id = episodeId,
|
||||
seasonId = seasonId,
|
||||
seriesId = seriesId
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,35 +134,13 @@ class HomePageViewModel @Inject constructor(
|
||||
|
||||
fun loadContinueWatching() {
|
||||
viewModelScope.launch {
|
||||
val continueWatching: List<BaseItemDto> = jellyfinApiClient.getContinueWatching()
|
||||
_continueWatching.value = continueWatching.map {
|
||||
if (it.type == BaseItemKind.EPISODE) {
|
||||
ContinueWatchingItem(
|
||||
id = it.id,
|
||||
type = BaseItemKind.EPISODE,
|
||||
primaryText = it.seriesName!!,
|
||||
secondaryText = "S${it.parentIndexNumber!!}:${it.indexNumber!!} - ${it.name!!}",
|
||||
progress = it.userData!!.playedPercentage!!,
|
||||
colors = listOf(Color.Red, Color.Green),
|
||||
)
|
||||
} else {
|
||||
ContinueWatchingItem(
|
||||
id = it.id,
|
||||
type = BaseItemKind.MOVIE,
|
||||
primaryText = it.name!!,
|
||||
secondaryText = it.premiereDate!!.format(DateTimeFormatter.ofLocalizedDate(
|
||||
FormatStyle.MEDIUM)),
|
||||
progress = it.userData!!.playedPercentage!!,
|
||||
colors = listOf(Color.Red, Color.Green)
|
||||
)
|
||||
}
|
||||
}
|
||||
// mediaRepository.loadContinueWatching()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLibraries() {
|
||||
viewModelScope.launch {
|
||||
loadLibrariesInternal()
|
||||
// mediaRepository.loadLibraries()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,35 +157,6 @@ class HomePageViewModel @Inject constructor(
|
||||
_libraries.value = mappedLibraries
|
||||
}
|
||||
|
||||
fun loadAllLibraryItems() {
|
||||
viewModelScope.launch {
|
||||
if (_libraries.value.isEmpty()) {
|
||||
loadLibrariesInternal()
|
||||
}
|
||||
_libraries.value.forEach { library ->
|
||||
loadLibraryItems(library.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLibraryItems(libraryId: UUID) {
|
||||
viewModelScope.launch {
|
||||
val libraryItems: List<BaseItemDto> = jellyfinApiClient.getLibrary(libraryId)
|
||||
// It return only Movie or Series
|
||||
val libraryPosterItems = libraryItems.map {
|
||||
PosterItem(
|
||||
id = it.id,
|
||||
title = it.name ?: "Unknown",
|
||||
type = it.type,
|
||||
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
|
||||
)
|
||||
}
|
||||
_libraryItems.update { currentMap ->
|
||||
currentMap + (libraryId to libraryPosterItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAllShownLibraryItems() {
|
||||
viewModelScope.launch {
|
||||
if (_libraries.value.isEmpty()) {
|
||||
@@ -177,30 +169,47 @@ class HomePageViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun loadLatestLibraryItems(libraryId: UUID) {
|
||||
if (_libraryItems.value.containsKey(libraryId)) return
|
||||
viewModelScope.launch {
|
||||
val latestLibraryItems = jellyfinApiClient.getLatestFromLibrary(libraryId)
|
||||
val latestLibraryPosterItem = latestLibraryItems.mapNotNull {
|
||||
val latestLibraryPosterItem = latestLibraryItems.map {
|
||||
when (it.type) {
|
||||
BaseItemKind.MOVIE -> PosterItem(
|
||||
id = it.id,
|
||||
title = it.name ?: "Unknown",
|
||||
type = BaseItemKind.MOVIE,
|
||||
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
|
||||
)
|
||||
BaseItemKind.EPISODE -> PosterItem(
|
||||
id = it.id,
|
||||
title = it.seriesName ?: "Unknown",
|
||||
type = BaseItemKind.EPISODE,
|
||||
imageUrl = getImageUrl(it.parentId!!, ImageType.PRIMARY)
|
||||
)
|
||||
BaseItemKind.SEASON -> PosterItem(
|
||||
id = it.seriesId!!,
|
||||
title = it.seriesName ?: "Unknown",
|
||||
type = BaseItemKind.SERIES,
|
||||
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
|
||||
)
|
||||
else -> null
|
||||
BaseItemKind.MOVIE -> {
|
||||
val movie = mediaRepository.getMovie(it.id)
|
||||
PosterItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = movie
|
||||
)
|
||||
}
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = mediaRepository.getEpisode(
|
||||
it.seriesId!!,
|
||||
it.parentId!!,
|
||||
it.id
|
||||
)
|
||||
PosterItem(
|
||||
type = BaseItemKind.EPISODE,
|
||||
episode = episode
|
||||
)
|
||||
}
|
||||
BaseItemKind.SEASON -> {
|
||||
val series = mediaRepository.getSeries(
|
||||
seriesId = it.seriesId!!
|
||||
)
|
||||
PosterItem(
|
||||
type = BaseItemKind.SERIES,
|
||||
series = series
|
||||
)
|
||||
}
|
||||
BaseItemKind.SERIES -> {
|
||||
val series = mediaRepository.getSeries(
|
||||
seriesId = it.id
|
||||
)
|
||||
PosterItem(
|
||||
type = BaseItemKind.SERIES,
|
||||
series = series
|
||||
)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
|
||||
}
|
||||
}.distinctBy { it.id }
|
||||
_latestLibraryContent.update { currentMap ->
|
||||
@@ -211,8 +220,6 @@ class HomePageViewModel @Inject constructor(
|
||||
|
||||
fun loadHomePageData() {
|
||||
loadContinueWatching()
|
||||
loadLibraries()
|
||||
loadAllLibraryItems()
|
||||
loadAllShownLibraryItems()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
package hu.bbara.purefin.app.home.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
||||
data class ContinueWatchingItem(
|
||||
val id: UUID,
|
||||
val type: BaseItemKind,
|
||||
val primaryText: String,
|
||||
val secondaryText: String,
|
||||
val progress: Double,
|
||||
val colors: List<Color>
|
||||
)
|
||||
val movie: Movie? = null,
|
||||
val episode: Episode? = null
|
||||
) {
|
||||
val id: UUID = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.id
|
||||
BaseItemKind.EPISODE -> episode!!.id
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||
}
|
||||
val primaryText: String = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.title
|
||||
BaseItemKind.EPISODE -> episode!!.title
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||
}
|
||||
val secondaryText: String = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.year
|
||||
BaseItemKind.EPISODE -> episode!!.releaseDate
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||
}
|
||||
val progress: Double = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.progress ?: 0.0
|
||||
BaseItemKind.EPISODE -> episode!!.progress ?: 0.0
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: $type")
|
||||
}
|
||||
}
|
||||
|
||||
data class LibraryItem(
|
||||
val id: UUID,
|
||||
@@ -23,11 +43,31 @@ data class LibraryItem(
|
||||
)
|
||||
|
||||
data class PosterItem(
|
||||
val id: UUID,
|
||||
val title: String,
|
||||
val type: BaseItemKind,
|
||||
val imageUrl: String
|
||||
)
|
||||
val movie: Movie? = null,
|
||||
val series: Series? = null,
|
||||
val episode: Episode? = null
|
||||
) {
|
||||
val id: UUID = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.id
|
||||
BaseItemKind.EPISODE -> episode!!.id
|
||||
BaseItemKind.SERIES -> series!!.id
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
val title: String = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.title
|
||||
BaseItemKind.EPISODE -> episode!!.title
|
||||
BaseItemKind.SERIES -> series!!.name
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
val imageUrl: String = when (type) {
|
||||
BaseItemKind.MOVIE -> movie!!.heroImageUrl
|
||||
BaseItemKind.EPISODE -> episode!!.heroImageUrl
|
||||
BaseItemKind.SERIES -> series!!.heroImageUrl
|
||||
else -> throw IllegalArgumentException("Invalid type: $type")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class HomeNavItem(
|
||||
val id: UUID,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.app.home.ui.PosterItem
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.data.InMemoryMediaRepository
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.navigation.ItemDto
|
||||
import hu.bbara.purefin.navigation.MovieDto
|
||||
import hu.bbara.purefin.navigation.NavigationManager
|
||||
import hu.bbara.purefin.navigation.Route
|
||||
import hu.bbara.purefin.navigation.SeriesDto
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -22,6 +24,7 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LibraryViewModel @Inject constructor(
|
||||
private val mediaRepository: InMemoryMediaRepository,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val navigationManager: NavigationManager
|
||||
@@ -35,13 +38,25 @@ class LibraryViewModel @Inject constructor(
|
||||
private val _contents = MutableStateFlow<List<PosterItem>>(emptyList())
|
||||
val contents = _contents.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch { mediaRepository.ensureReady() }
|
||||
}
|
||||
|
||||
fun onMovieSelected(movieId: String) {
|
||||
navigationManager.navigate(Route.MovieRoute(ItemDto(id = UUID.fromString(movieId), type = BaseItemKind.MOVIE)))
|
||||
navigationManager.navigate(Route.MovieRoute(
|
||||
MovieDto(
|
||||
id = UUID.fromString(movieId),
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fun onSeriesSelected(seriesId: String) {
|
||||
viewModelScope.launch {
|
||||
navigationManager.navigate(Route.SeriesRoute(ItemDto(id = UUID.fromString(seriesId), type = BaseItemKind.SERIES)))
|
||||
navigationManager.navigate(Route.SeriesRoute(
|
||||
SeriesDto(
|
||||
id = UUID.fromString(seriesId),
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,14 +66,25 @@ class LibraryViewModel @Inject constructor(
|
||||
|
||||
fun selectLibrary(libraryId: UUID) {
|
||||
viewModelScope.launch {
|
||||
val libraryItems = jellyfinApiClient.getLibrary(libraryId)
|
||||
val libraryItems = jellyfinApiClient.getLibraryContent(libraryId)
|
||||
_contents.value = libraryItems.map {
|
||||
PosterItem(
|
||||
id = it.id,
|
||||
title = it.name ?: "Unknown",
|
||||
type = it.type,
|
||||
imageUrl = getImageUrl(it.id, ImageType.PRIMARY)
|
||||
)
|
||||
when (it.type) {
|
||||
BaseItemKind.MOVIE -> {
|
||||
val movie = mediaRepository.getMovie(it.id)
|
||||
PosterItem(
|
||||
type = BaseItemKind.MOVIE,
|
||||
movie = movie
|
||||
)
|
||||
}
|
||||
BaseItemKind.SERIES -> {
|
||||
val series = mediaRepository.getSeries(it.id)
|
||||
PosterItem(
|
||||
type = BaseItemKind.SERIES,
|
||||
series = series
|
||||
)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,4 +96,4 @@ class LibraryViewModel @Inject constructor(
|
||||
type = type
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
@@ -89,6 +90,9 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED),
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||
enableUserData = true,
|
||||
startIndex = 0,
|
||||
)
|
||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||
@@ -102,13 +106,15 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
val response = api.userViewsApi.getUserViews(
|
||||
userId = getUserId(),
|
||||
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
||||
includeHidden = false,
|
||||
)
|
||||
Log.d("getLibraries response: {}", response.content.toString())
|
||||
return response.content.items
|
||||
val libraries = response.content.items
|
||||
return libraries
|
||||
}
|
||||
|
||||
suspend fun getLibrary(libraryId: UUID): List<BaseItemDto> {
|
||||
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> {
|
||||
if (!ensureConfigured()) {
|
||||
return emptyList()
|
||||
}
|
||||
@@ -116,14 +122,13 @@ class JellyfinApiClient @Inject constructor(
|
||||
userId = getUserId(),
|
||||
enableImages = false,
|
||||
parentId = libraryId,
|
||||
enableUserData = false,
|
||||
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED),
|
||||
enableUserData = true,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
// recursive = true,
|
||||
// TODO remove this limit
|
||||
// limit = 10
|
||||
recursive = true,
|
||||
)
|
||||
val response = api.itemsApi.getItems(getItemsRequest)
|
||||
Log.d("getLibrary response: {}", response.content.toString())
|
||||
Log.d("getLibraryContent response: {}", response.content.toString())
|
||||
return response.content.items
|
||||
}
|
||||
|
||||
@@ -140,6 +145,7 @@ class JellyfinApiClient @Inject constructor(
|
||||
val response = api.userLibraryApi.getLatestMedia(
|
||||
userId = getUserId(),
|
||||
parentId = libraryId,
|
||||
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED),
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
||||
limit = 10
|
||||
)
|
||||
@@ -166,7 +172,7 @@ class JellyfinApiClient @Inject constructor(
|
||||
val result = api.tvShowsApi.getSeasons(
|
||||
userId = getUserId(),
|
||||
seriesId = seriesId,
|
||||
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID),
|
||||
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED),
|
||||
enableUserData = true
|
||||
)
|
||||
Log.d("getSeasons response: {}", result.content.toString())
|
||||
@@ -181,7 +187,7 @@ class JellyfinApiClient @Inject constructor(
|
||||
userId = getUserId(),
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId,
|
||||
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID),
|
||||
fields = listOf(ItemFields.CHILD_COUNT, ItemFields.PARENT_ID, ItemFields.DATE_LAST_REFRESHED),
|
||||
enableUserData = true
|
||||
)
|
||||
Log.d("getEpisodesInSeason response: {}", result.content.toString())
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.data.model.CastMember
|
||||
|
||||
@Composable
|
||||
fun MediaMetaChip(
|
||||
@@ -62,15 +63,9 @@ fun MediaMetaChip(
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaCastMember(
|
||||
val name: String,
|
||||
val role: String,
|
||||
val imageUrl: String?
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MediaCastRow(
|
||||
cast: List<MediaCastMember>,
|
||||
cast: List<CastMember>,
|
||||
modifier: Modifier = Modifier,
|
||||
cardWidth: Dp = 96.dp,
|
||||
nameSize: TextUnit = 12.sp,
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import hu.bbara.purefin.client.JellyfinApiClient
|
||||
import hu.bbara.purefin.data.local.room.RoomMediaLocalDataSource
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Library
|
||||
import hu.bbara.purefin.data.model.Media
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Season
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||
import hu.bbara.purefin.session.UserSessionRepository
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -21,73 +33,201 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class InMemoryMediaRepository @Inject constructor(
|
||||
val userSessionRepository: UserSessionRepository,
|
||||
val jellyfinApiClient: JellyfinApiClient
|
||||
val jellyfinApiClient: JellyfinApiClient,
|
||||
private val localDataSource: RoomMediaLocalDataSource
|
||||
) : MediaRepository {
|
||||
|
||||
val seriesCache : LruCache<UUID, Series> = LruCache(100)
|
||||
private val ready = CompletableDeferred<Unit>()
|
||||
|
||||
override suspend fun getSeries(
|
||||
seriesId: UUID,
|
||||
includeContent: Boolean
|
||||
): Series {
|
||||
val series = fetchAndUpdateSeriesIfMissing(seriesId)
|
||||
if (includeContent.not()) {
|
||||
return series.copy(seasons = emptyList())
|
||||
private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Loading)
|
||||
override val state: StateFlow<MediaRepositoryState> = _state
|
||||
|
||||
private val _movies : MutableStateFlow<Map<UUID, Movie>> = MutableStateFlow(emptyMap())
|
||||
override val movies: StateFlow<Map<UUID, Movie>> = _movies
|
||||
|
||||
private val _series : MutableStateFlow<Map<UUID, Series>> = MutableStateFlow(emptyMap())
|
||||
override val series: StateFlow<Map<UUID, Series>> = _series
|
||||
|
||||
private val _continueWatching: MutableStateFlow<List<Media>> = MutableStateFlow(emptyList())
|
||||
val continueWatching: StateFlow<List<Media>> = _continueWatching
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
runCatching { ensureReady() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun ensureReady() {
|
||||
if (ready.isCompleted) return
|
||||
|
||||
try {
|
||||
loadLibraries()
|
||||
loadContinueWatching()
|
||||
_state.value = MediaRepositoryState.Ready
|
||||
ready.complete(Unit)
|
||||
} catch (t: Throwable) {
|
||||
_state.value = MediaRepositoryState.Error(t)
|
||||
ready.completeExceptionally(t)
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitReady() {
|
||||
ready.await()
|
||||
}
|
||||
|
||||
suspend fun loadLibraries() {
|
||||
val librariesItem = jellyfinApiClient.getLibraries()
|
||||
//TODO add support for playlists
|
||||
val filteredLibraries =
|
||||
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
|
||||
val emptyLibraries = filteredLibraries.map {
|
||||
it.toLibrary()
|
||||
}
|
||||
val filledLibraries = emptyLibraries.map { library ->
|
||||
return@map loadLibrary(library)
|
||||
}
|
||||
val movies = filledLibraries.filter { it.type == CollectionType.MOVIES }.flatMap { it.movies.orEmpty() }
|
||||
localDataSource.saveMovies(movies)
|
||||
_movies.value = localDataSource.getMovies().associateBy { it.id }
|
||||
|
||||
val series = filledLibraries.filter { it.type == CollectionType.TVSHOWS }.flatMap { it.series.orEmpty() }
|
||||
localDataSource.saveSeries(series)
|
||||
_series.value = localDataSource.getSeries().associateBy { it.id }
|
||||
}
|
||||
|
||||
suspend fun loadLibrary(library: Library): Library {
|
||||
val contentItem = jellyfinApiClient.getLibraryContent(library.id)
|
||||
when (library.type) {
|
||||
CollectionType.MOVIES -> {
|
||||
val movies = contentItem.map { it.toMovie(serverUrl(), library.id) }
|
||||
return library.copy(movies = movies)
|
||||
}
|
||||
CollectionType.TVSHOWS -> {
|
||||
val series = contentItem.map { it.toSeries(serverUrl(), library.id) }
|
||||
return library.copy(series = series)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: ${library.type}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadMovie(movie: Movie) : Movie {
|
||||
val movieItem = jellyfinApiClient.getItemInfo(movie.id)
|
||||
?: throw RuntimeException("Movie not found")
|
||||
val updatedMovie = movieItem.toMovie(serverUrl(), movie.libraryId)
|
||||
localDataSource.saveMovies(listOf(updatedMovie))
|
||||
_movies.value += (movie.id to updatedMovie)
|
||||
return updatedMovie
|
||||
}
|
||||
|
||||
suspend fun loadSeries(series: Series) : Series {
|
||||
val seriesItem = jellyfinApiClient.getItemInfo(series.id)
|
||||
?: throw RuntimeException("Series not found")
|
||||
val updatedSeries = seriesItem.toSeries(serverUrl(), series.libraryId)
|
||||
localDataSource.saveSeries(listOf(updatedSeries))
|
||||
_series.value += (series.id to updatedSeries)
|
||||
return updatedSeries
|
||||
}
|
||||
|
||||
suspend fun loadContinueWatching() {
|
||||
val continueWatchingItems = jellyfinApiClient.getContinueWatching()
|
||||
val items = continueWatchingItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||
BaseItemKind.EPISODE -> Media.EpisodeMedia(
|
||||
episodeId = item.id,
|
||||
seriesId = item.seriesId!!
|
||||
)
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
|
||||
}
|
||||
}
|
||||
_continueWatching.value = items
|
||||
|
||||
//Load episodes, Movies are already loaded at this point.
|
||||
continueWatchingItems.forEach { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = item.toEpisode(serverUrl())
|
||||
localDataSource.saveEpisode(episode)
|
||||
}
|
||||
else -> { /* Do nothing */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMovie(movieId: UUID): Movie {
|
||||
awaitReady()
|
||||
localDataSource.getMovie(movieId)?.let {
|
||||
_movies.value += (movieId to it)
|
||||
return it
|
||||
}
|
||||
throw RuntimeException("Movie not found")
|
||||
}
|
||||
|
||||
override suspend fun getSeries(seriesId: UUID): Series {
|
||||
awaitReady()
|
||||
localDataSource.getSeriesBasic(seriesId)?.let {
|
||||
_series.value += (seriesId to it)
|
||||
return it
|
||||
}
|
||||
throw RuntimeException("Series not found")
|
||||
}
|
||||
|
||||
override suspend fun getSeriesWithContent(seriesId: UUID): Series {
|
||||
awaitReady()
|
||||
// Use cached content if available
|
||||
localDataSource.getSeriesWithContent(seriesId)?.takeIf { it.seasons.isNotEmpty() }?.let {
|
||||
_series.value += (seriesId to it)
|
||||
return it
|
||||
}
|
||||
|
||||
if (hasContent(series)) {
|
||||
return series
|
||||
}
|
||||
val series = _series.value[seriesId] ?: throw RuntimeException("Series not found")
|
||||
|
||||
val seasons = getSeasons(
|
||||
seriesId = seriesId,
|
||||
includeContent = true
|
||||
)
|
||||
return series.copy(seasons = seasons)
|
||||
val emptySeasonsItem = jellyfinApiClient.getSeasons(seriesId)
|
||||
val emptySeasons = emptySeasonsItem.map { it.toSeason(serverUrl()) }
|
||||
val filledSeasons = emptySeasons.map { season ->
|
||||
val episodesItem = jellyfinApiClient.getEpisodesInSeason(seriesId, season.id)
|
||||
val episodes = episodesItem.map { it.toEpisode(serverUrl()) }
|
||||
season.copy(episodes = episodes)
|
||||
}
|
||||
val updatedSeries = series.copy(seasons = filledSeasons)
|
||||
localDataSource.saveSeries(listOf(updatedSeries))
|
||||
localDataSource.saveSeriesContent(updatedSeries)
|
||||
_series.value += (series.id to updatedSeries)
|
||||
return updatedSeries
|
||||
}
|
||||
|
||||
override suspend fun getSeason(
|
||||
seriesId: UUID,
|
||||
seasonId: UUID,
|
||||
includeContent: Boolean
|
||||
): Season {
|
||||
val season = fetchAndUpdateSeasonIfMissing(seriesId, seasonId)
|
||||
if (includeContent.not()) {
|
||||
return season.copy(episodes = emptyList())
|
||||
}
|
||||
|
||||
if (hasContent(season)) {
|
||||
return season
|
||||
}
|
||||
|
||||
val episodes = getEpisodes(
|
||||
seriesId = seriesId,
|
||||
seasonId = seasonId
|
||||
)
|
||||
return season.copy(episodes = episodes)
|
||||
awaitReady()
|
||||
localDataSource.getSeason(seriesId, seasonId)?.let { return it }
|
||||
// Fallback: ensure series content is loaded, then retry
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons.find { it.id == seasonId }?: throw RuntimeException("Season not found")
|
||||
}
|
||||
|
||||
override suspend fun getSeasons(
|
||||
seriesId: UUID,
|
||||
includeContent: Boolean
|
||||
): List<Season> {
|
||||
val cachedSeasons = fetchAndUpdateSeasonsIfMissing(seriesId)
|
||||
if (includeContent.not()) {
|
||||
return cachedSeasons.map { it.copy(episodes = emptyList()) }
|
||||
}
|
||||
awaitReady()
|
||||
val seasons = localDataSource.getSeasons(seriesId)
|
||||
if (seasons.isNotEmpty()) return seasons
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons
|
||||
}
|
||||
|
||||
val hasContent = cachedSeasons.all { season ->
|
||||
hasContent(season)
|
||||
}
|
||||
if (hasContent) {
|
||||
return cachedSeasons
|
||||
}
|
||||
|
||||
return cachedSeasons.map { season ->
|
||||
// TODO use batch api that gives back all of the episodes in a single request
|
||||
val episodes = getEpisodes(seriesId, season.id)
|
||||
season.copy(episodes = episodes)
|
||||
}
|
||||
override suspend fun getEpisode(
|
||||
seriesId: UUID,
|
||||
episodeId: UUID
|
||||
) : Episode {
|
||||
awaitReady()
|
||||
localDataSource.getEpisodeById(episodeId)?.let { return it }
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons.flatMap { it.episodes }.find { it.id == episodeId }?: throw RuntimeException("Episode not found")
|
||||
}
|
||||
|
||||
override suspend fun getEpisode(
|
||||
@@ -95,114 +235,81 @@ class InMemoryMediaRepository @Inject constructor(
|
||||
seasonId: UUID,
|
||||
episodeId: UUID
|
||||
): Episode {
|
||||
val cachedSeason = fetchAndUpdateSeasonIfMissing(seriesId, seasonId)
|
||||
cachedSeason.episodes.find { it.id == episodeId }?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val episodesItemInfo = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
|
||||
val episodes = episodesItemInfo.map { it.toEpisode(serverUrl()) }
|
||||
val cachedSeries = seriesCache[seriesId]!!
|
||||
val season = cachedSeason.copy(episodes = episodes)
|
||||
val updatedSeasons = cachedSeries.seasons.map { if (it.id == seasonId) season else it }
|
||||
val updatedSeries = cachedSeries.copy(seasons = updatedSeasons)
|
||||
seriesCache.put(seriesId, updatedSeries)
|
||||
return episodes.find { it.id == episodeId }!!
|
||||
awaitReady()
|
||||
localDataSource.getEpisode(seriesId, seasonId, episodeId)?.let { return it }
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons.find { it.id == seasonId }?.episodes?.find { it.id == episodeId } ?: throw RuntimeException("Episode not found")
|
||||
}
|
||||
|
||||
override suspend fun getEpisodes(
|
||||
seriesId: UUID,
|
||||
seasonId: UUID
|
||||
): List<Episode> {
|
||||
val cachedSeason = fetchAndUpdateSeasonIfMissing(seriesId, seasonId)
|
||||
if (hasContent(cachedSeason)) {
|
||||
return cachedSeason.episodes
|
||||
}
|
||||
|
||||
val episodesItemInfo = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
|
||||
val episodes = episodesItemInfo.map { it.toEpisode(serverUrl()) }
|
||||
val cachedSeries = seriesCache[seriesId]!!
|
||||
val updatedSeason = cachedSeason.copy(episodes = episodes)
|
||||
val updateSeries = cachedSeries.copy(seasons = cachedSeries.seasons.map { if (it.id == seasonId) updatedSeason else it })
|
||||
seriesCache.put(seriesId, updateSeries)
|
||||
return episodes
|
||||
awaitReady()
|
||||
val episodes = localDataSource.getSeason(seriesId, seasonId)?.episodes
|
||||
if (episodes != null && episodes.isNotEmpty()) return episodes
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons.find { it.id == seasonId }?.episodes ?: throw RuntimeException("Season not found")
|
||||
}
|
||||
|
||||
override suspend fun getEpisodes(seriesId: UUID): List<Episode> {
|
||||
val cachedSeasons = fetchAndUpdateSeasonsIfMissing(seriesId)
|
||||
if (cachedSeasons.all { hasContent(it) }) {
|
||||
return cachedSeasons.flatMap { it.episodes }
|
||||
}
|
||||
|
||||
return cachedSeasons.flatMap { season ->
|
||||
getEpisodes(seriesId, season.id)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAndUpdateSeriesIfMissing(seriesId: UUID): Series {
|
||||
val cachedSeries = seriesCache[seriesId]
|
||||
if (cachedSeries == null) {
|
||||
val seriesItemInfo = jellyfinApiClient.getItemInfo(seriesId)
|
||||
?: throw RuntimeException("Series not found")
|
||||
val series = seriesItemInfo.toSeries(serverUrl())
|
||||
seriesCache.put(seriesId, series)
|
||||
}
|
||||
return seriesCache[seriesId]!!
|
||||
}
|
||||
|
||||
private suspend fun fetchAndUpdateSeasonIfMissing(seriesId: UUID, seasonId: UUID): Season {
|
||||
val cachedSeries = fetchAndUpdateSeriesIfMissing(seriesId)
|
||||
cachedSeries.seasons.find { it.id == seasonId }?.let {
|
||||
return it
|
||||
}
|
||||
val seasonsItemInfo = jellyfinApiClient.getSeasons(seriesId)
|
||||
val seasons = seasonsItemInfo.map { it.toSeason(serverUrl()) }
|
||||
val series = cachedSeries.copy(
|
||||
seasons = seasons
|
||||
)
|
||||
seriesCache.put(seriesId, series)
|
||||
return seasons.find { it.id == seasonId }!!
|
||||
}
|
||||
|
||||
private suspend fun fetchAndUpdateSeasonsIfMissing(seriesId: UUID): List<Season> {
|
||||
val cachedSeries = fetchAndUpdateSeriesIfMissing(seriesId)
|
||||
if (cachedSeries.seasons.size == cachedSeries.seasonCount) {
|
||||
return cachedSeries.seasons
|
||||
}
|
||||
|
||||
val seasonsItemInfo = jellyfinApiClient.getSeasons(seriesId)
|
||||
val seasons = seasonsItemInfo.map { it.toSeason(serverUrl()) }
|
||||
val series = cachedSeries.copy(
|
||||
seasons = seasons
|
||||
)
|
||||
seriesCache.put(seriesId, series)
|
||||
return seasons
|
||||
|
||||
}
|
||||
|
||||
private fun hasContent(series: Series): Boolean {
|
||||
if (series.seasons.size != series.seasonCount) {
|
||||
return false
|
||||
}
|
||||
for (season in series.seasons) {
|
||||
if (hasContent(season).not()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hasContent(season: Season) : Boolean {
|
||||
return season.episodes.size == season.episodeCount
|
||||
awaitReady()
|
||||
val episodes = localDataSource.getEpisodesBySeries(seriesId)
|
||||
if (episodes.isNotEmpty()) return episodes
|
||||
val series = getSeriesWithContent(seriesId)
|
||||
return series.seasons.flatMap { it.episodes }
|
||||
}
|
||||
|
||||
private suspend fun serverUrl(): String {
|
||||
return userSessionRepository.serverUrl.first()
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toSeries(serverUrl: String): Series {
|
||||
private fun BaseItemDto.toLibrary(): Library {
|
||||
return when (this.collectionType) {
|
||||
CollectionType.MOVIES -> Library(
|
||||
id = this.id,
|
||||
name = this.name!!,
|
||||
type = CollectionType.MOVIES,
|
||||
movies = emptyList()
|
||||
)
|
||||
CollectionType.TVSHOWS -> Library(
|
||||
id = this.id,
|
||||
name = this.name!!,
|
||||
type = CollectionType.TVSHOWS,
|
||||
series = emptyList()
|
||||
)
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: ${this.collectionType}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toMovie(serverUrl: String, libraryId: UUID) : Movie {
|
||||
return Movie(
|
||||
id = this.id,
|
||||
libraryId = libraryId,
|
||||
title = this.name ?: "Unknown title",
|
||||
progress = this.userData!!.playedPercentage,
|
||||
watched = this.userData!!.played,
|
||||
year = this.productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(),
|
||||
rating = this.officialRating
|
||||
?: "NR",
|
||||
runtime = formatRuntime(this.runTimeTicks),
|
||||
synopsis = this.overview ?: "No synopsis available",
|
||||
format = container?.uppercase() ?: "VIDEO",
|
||||
heroImageUrl = JellyfinImageHelper.toImageUrl(
|
||||
url = serverUrl,
|
||||
itemId = this.id,
|
||||
type = ImageType.PRIMARY
|
||||
),
|
||||
subtitles = "ENG",
|
||||
audioTrack = "ENG",
|
||||
cast = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun BaseItemDto.toSeries(serverUrl: String, libraryId: UUID): Series {
|
||||
return Series(
|
||||
id = this.id,
|
||||
libraryId = libraryId,
|
||||
name = this.name ?: "Unknown",
|
||||
synopsis = this.overview ?: "No synopsis available",
|
||||
year = this.productionYear?.toString()
|
||||
@@ -279,4 +386,4 @@ class InMemoryMediaRepository @Inject constructor(
|
||||
"${minutes}m"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
package hu.bbara.purefin.data
|
||||
|
||||
import hu.bbara.purefin.data.model.Episode
|
||||
import hu.bbara.purefin.data.model.Movie
|
||||
import hu.bbara.purefin.data.model.Season
|
||||
import hu.bbara.purefin.data.model.Series
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.UUID
|
||||
|
||||
interface MediaRepository {
|
||||
|
||||
suspend fun getSeries(seriesId: UUID, includeContent: Boolean) : Series
|
||||
val movies: StateFlow<Map<UUID, Movie>>
|
||||
val series: StateFlow<Map<UUID, Series>>
|
||||
val state: StateFlow<MediaRepositoryState>
|
||||
|
||||
suspend fun getSeason(seriesId: UUID, seasonId: UUID, includeContent: Boolean) : Season
|
||||
|
||||
suspend fun getSeasons(seriesId: UUID, includeContent: Boolean) : List<Season>
|
||||
|
||||
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode
|
||||
|
||||
suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List<Episode>
|
||||
suspend fun ensureReady()
|
||||
|
||||
suspend fun getMovie(movieId: UUID) : Movie
|
||||
suspend fun getSeries(seriesId: UUID) : Series
|
||||
suspend fun getSeriesWithContent(seriesId: UUID) : Series
|
||||
suspend fun getSeasons(seriesId: UUID) : List<Season>
|
||||
suspend fun getSeason(seriesId: UUID, seasonId: UUID) : Season
|
||||
suspend fun getEpisodes(seriesId: UUID) : List<Episode>
|
||||
suspend fun getEpisodes(seriesId: UUID, seasonId: UUID) : List<Episode>
|
||||
suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID) : Episode
|
||||
suspend fun getEpisode(seriesId: UUID, episodeId: UUID) : Episode
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
18
app/src/main/java/hu/bbara/purefin/data/model/Library.kt
Normal file
18
app/src/main/java/hu/bbara/purefin/data/model/Library.kt
Normal 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" }
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/hu/bbara/purefin/data/model/Media.kt
Normal file
13
app/src/main/java/hu/bbara/purefin/data/model/Media.kt
Normal 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)
|
||||
}
|
||||
20
app/src/main/java/hu/bbara/purefin/data/model/Movie.kt
Normal file
20
app/src/main/java/hu/bbara/purefin/data/model/Movie.kt
Normal 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>
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
15
app/src/main/java/hu/bbara/purefin/navigation/EpisodeDto.kt
Normal file
15
app/src/main/java/hu/bbara/purefin/navigation/EpisodeDto.kt
Normal 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,
|
||||
)
|
||||
@@ -1,13 +1,11 @@
|
||||
package hu.bbara.purefin.navigation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class ItemDto (
|
||||
data class MovieDto(
|
||||
@Serializable(with = UUIDSerializer::class)
|
||||
val id: UUID,
|
||||
val type : BaseItemKind
|
||||
)
|
||||
val id: UUID
|
||||
)
|
||||
@@ -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
|
||||
|
||||
11
app/src/main/java/hu/bbara/purefin/navigation/SeriesDto.kt
Normal file
11
app/src/main/java/hu/bbara/purefin/navigation/SeriesDto.kt
Normal 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
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user