diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt index 0f4efc2a..3f117ad9 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/TvSeriesScreen.kt @@ -78,10 +78,10 @@ internal fun TvSeriesScreenContent( var selectedSeasonId by remember(series.id, focusedSeasonId) { mutableStateOf(defaultSeason?.id) } - val selectedSeason = series.seasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason + val selectedSeason = series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: defaultSeason val initialFocusSeasonId = defaultSeason?.id val initialFocusSeason = initialFocusSeasonId?.let { seasonId -> - series.seasons.firstOrNull { it.id == seasonId } + series.allSeasons.firstOrNull { it.id == seasonId } } ?: defaultSeason val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId) val seasonTabFocusRequester = remember { FocusRequester() } @@ -120,7 +120,7 @@ internal fun TvSeriesScreenContent( Spacer(modifier = Modifier.height(16.dp)) if (selectedSeason != null) { TvSeasonTabs( - seasons = series.seasons, + seasons = series.allSeasons, selectedSeason = selectedSeason, selectedItemFocusRequester = seasonTabFocusRequester, firstItemTestTag = SeriesFirstSeasonTabTag, @@ -156,10 +156,10 @@ internal fun TvSeriesScreenContent( private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? { if (focusedSeasonId != null) { - seasons.firstOrNull { it.id == focusedSeasonId }?.let { return it } + allSeasons.firstOrNull { it.id == focusedSeasonId }?.let { return it } } - return seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: seasons.firstOrNull() + return allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: allSeasons.firstOrNull() } private fun Season.nextUpEpisode(): Episode? { diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt index 6f78b21b..69713996 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/series/SeriesScreen.kt @@ -149,12 +149,12 @@ private fun SeriesScreenInternal( var showMarkAsWatchedDialog by remember { mutableStateOf(false) } fun getDefaultSeason(): Season? { - return series.seasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.seasons.firstOrNull() + return series.allSeasons.firstOrNull { it.unwatchedEpisodeCount > 0 } ?: series.allSeasons.firstOrNull() } var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason()?.id) } val selectedSeason = - series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason() + series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason() val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched } ?: selectedSeason?.episodes?.firstOrNull() val playAction = remember(nextUpEpisode, offline) { @@ -210,7 +210,7 @@ private fun SeriesScreenInternal( ) { _modifier -> if (selectedSeason != null) { SeasonTabs( - seasons = series.seasons, + seasons = series.allSeasons, selectedSeason = selectedSeason, onSelect = { selectedSeasonId = it.id }, modifier = _modifier diff --git a/core-model/src/main/java/hu/bbara/purefin/model/Series.kt b/core-model/src/main/java/hu/bbara/purefin/model/Series.kt index 0f167373..cc7d6468 100644 --- a/core-model/src/main/java/hu/bbara/purefin/model/Series.kt +++ b/core-model/src/main/java/hu/bbara/purefin/model/Series.kt @@ -2,6 +2,10 @@ package hu.bbara.purefin.model import java.util.UUID +val UNCATEGORIZED_SEASON_ID: UUID = UUID.fromString("c0ffee00-0000-0000-0000-000000000000") +val UNCATEGORIZED_SERIES_ID: UUID = UUID.fromString("c0ffee00-0000-0000-0000-000000000001") +const val UNCATEGORIZED_LABEL: String = "Uncategorized" + data class Series( val id: UUID, val libraryId: UUID, @@ -12,5 +16,27 @@ data class Series( val unwatchedEpisodeCount: Int, val seasonCount: Int, val seasons: List, + val uncategorizedEpisodes: List = emptyList(), val cast: List -) +) { + + /** + * Real seasons plus a synthetic "Uncategorized" season appended at the end + * when [uncategorizedEpisodes] is non-empty. Used by the UI for the season + * tab list so uncategorized episodes are selectable like any other season. + */ + val allSeasons: List + get() = if (uncategorizedEpisodes.isEmpty()) { + seasons + } else { + seasons + Season( + id = UNCATEGORIZED_SEASON_ID, + seriesId = id, + name = UNCATEGORIZED_LABEL, + index = 0, + unwatchedEpisodeCount = uncategorizedEpisodes.count { !it.watched }, + episodeCount = uncategorizedEpisodes.size, + episodes = uncategorizedEpisodes, + ) + } +} diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt index d6893c93..eac2aad0 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt @@ -3,6 +3,7 @@ package hu.bbara.purefin.data.catalog import hu.bbara.purefin.core.concurrency.SingleFlight import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.UserSessionRepository +import hu.bbara.purefin.data.converter.isUncategorizedEpisode import hu.bbara.purefin.data.converter.toEpisode import hu.bbara.purefin.data.converter.toMovie import hu.bbara.purefin.data.converter.toSeason @@ -11,6 +12,7 @@ import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Series +import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -82,7 +84,10 @@ class InMemoryLocalMediaRepository @Inject constructor( seriesState.update { current -> val existing = current[series.id] val merged = if (existing != null && existing.seasons.isNotEmpty() && series.seasons.isEmpty()) { - series.copy(seasons = existing.seasons) + series.copy( + seasons = existing.seasons, + uncategorizedEpisodes = existing.uncategorizedEpisodes, + ) } else { series } @@ -124,7 +129,10 @@ class InMemoryLocalMediaRepository @Inject constructor( current + series.associateBy { it.id }.mapValues { (id, newSeries) -> val existing = current[id] if (existing != null && existing.seasons.isNotEmpty() && newSeries.seasons.isEmpty()) { - newSeries.copy(seasons = existing.seasons) + newSeries.copy( + seasons = existing.seasons, + uncategorizedEpisodes = existing.uncategorizedEpisodes, + ) } else { newSeries } @@ -168,6 +176,11 @@ class InMemoryLocalMediaRepository @Inject constructor( override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) = singleFlight.run("LocalMedia:loadSeasonEpisodes:$seriesId:$seasonId") { try { + // The "Uncategorized" season is synthetic; its episodes are + // populated as a side effect of loading real seasons, so + // selecting that tab must not trigger a Jellyfin API call. + if (seasonId == UNCATEGORIZED_SEASON_ID) return@run + // Ensure the series and season is loaded var series = seriesState.value[seriesId] if (series == null) { @@ -187,22 +200,35 @@ class InMemoryLocalMediaRepository @Inject constructor( } val serverUrl = userSessionRepository.serverUrl.first() - val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId) - .map { it.toEpisode(serverUrl) } + val seriesName = seriesState.value[seriesId]?.name + val (categorized, uncategorized) = jellyfinApiClient + .getEpisodesInSeason(seriesId, seasonId) + .partition { !it.isUncategorizedEpisode() } + val categorizedEpisodes = categorized.map { + it.toEpisode(serverUrl, fallbackSeriesId = seriesId, fallbackSeriesName = seriesName) + } + val uncategorizedEpisodes = uncategorized.map { + it.toEpisode(serverUrl, fallbackSeriesId = seriesId, fallbackSeriesName = seriesName) + } seriesState.update { current -> val currentSeries = current[seriesId] ?: return@update current val updatedSeries = currentSeries.copy( seasons = currentSeries.seasons.map { if (it.id == seasonId && it.episodes.isEmpty()) { - it.copy(episodes = episodes) + it.copy(episodes = categorizedEpisodes) } else { it } - } + }, + uncategorizedEpisodes = ( + currentSeries.uncategorizedEpisodes + uncategorizedEpisodes + ).distinctBy { it.id } ) current + (updatedSeries.id to updatedSeries) } - episodesState.update { current -> current + episodes.associateBy { it.id } } + episodesState.update { current -> + current + (categorizedEpisodes + uncategorizedEpisodes).associateBy { it.id } + } } catch (error: CancellationException) { throw error } catch (error: Exception) { @@ -285,8 +311,21 @@ class InMemoryLocalMediaRepository @Inject constructor( ) } } + val uncategorizedEpisodes = if ( + series.uncategorizedEpisodes.none { it.id == updatedEpisode.id } + ) { + series.uncategorizedEpisodes + } else { + changed = true + series.uncategorizedEpisodes.map { episode -> + if (episode.id == updatedEpisode.id) updatedEpisode else episode + } + } if (changed) { - current + (series.id to series.copy(seasons = seasons)) + current + (series.id to series.copy( + seasons = seasons, + uncategorizedEpisodes = uncategorizedEpisodes, + )) } else { current } diff --git a/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt b/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt index 42e6247d..fc9bdaa6 100644 --- a/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt +++ b/data/src/main/java/hu/bbara/purefin/data/converter/BaseItemDtoConverter.kt @@ -9,12 +9,15 @@ import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Series +import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID +import hu.bbara.purefin.model.UNCATEGORIZED_SERIES_ID import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.CollectionType import timber.log.Timber import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.Locale +import java.util.UUID import java.util.concurrent.TimeUnit fun BaseItemDto.toLibrary(serverUrl: String): Library? { @@ -101,24 +104,38 @@ fun BaseItemDto.toSeason(): Season { ) } -fun BaseItemDto.toEpisode(serverUrl: String): Episode { +/** + * An episode is considered "uncategorized" when it is missing any of the + * identity/season fields required to place it under a real season. Missing + * watch data ([userData]) is intentionally NOT a trigger — it is defaulted + * so the episode can still appear under its real season. + */ +fun BaseItemDto.isUncategorizedEpisode(): Boolean = + parentId == null || seriesId == null || seriesName == null || + parentIndexNumber == null || indexNumber == null + +fun BaseItemDto.toEpisode( + serverUrl: String, + fallbackSeriesId: UUID? = null, + fallbackSeriesName: String? = null, +): Episode { val releaseDate = formatReleaseDate(premiereDate, productionYear) val imageUrlPrefix = id?.let { itemId -> ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId) } ?: "" return Episode( - id = id, - seriesId = seriesId!!, - seriesName = seriesName!!, - seasonId = parentId!!, - seasonIndex = parentIndexNumber!!, + id = id ?: error("Episode without id"), + seriesId = seriesId ?: fallbackSeriesId ?: UNCATEGORIZED_SERIES_ID, + seriesName = seriesName ?: fallbackSeriesName ?: "Unknown", + seasonId = parentId ?: UNCATEGORIZED_SEASON_ID, + seasonIndex = parentIndexNumber ?: 0, title = name ?: "Unknown title", - index = indexNumber!!, + index = indexNumber ?: 0, releaseDate = releaseDate, rating = officialRating ?: "NR", runtime = formatRuntime(runTimeTicks), - progress = userData!!.playedPercentage, - watched = userData!!.played, + progress = userData?.playedPercentage, + watched = userData?.played ?: false, format = container?.uppercase() ?: "VIDEO", synopsis = overview ?: "No synopsis available.", imageUrlPrefix = imageUrlPrefix, diff --git a/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineRoomMediaLocalDataSource.kt b/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineRoomMediaLocalDataSource.kt index a406d7d6..a8dcba73 100644 --- a/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineRoomMediaLocalDataSource.kt +++ b/data/src/main/java/hu/bbara/purefin/data/offline/room/offline/OfflineRoomMediaLocalDataSource.kt @@ -13,6 +13,8 @@ import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Series +import hu.bbara.purefin.model.UNCATEGORIZED_LABEL +import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.util.UUID @@ -78,6 +80,23 @@ class OfflineRoomMediaLocalDataSource( episodeDao.upsert(episode.toEntity()) } } + + if (series.uncategorizedEpisodes.isNotEmpty()) { + val unwatched = series.uncategorizedEpisodes.count { !it.watched } + val sentinelSeason = Season( + id = UNCATEGORIZED_SEASON_ID, + seriesId = series.id, + name = UNCATEGORIZED_LABEL, + index = 0, + unwatchedEpisodeCount = unwatched, + episodeCount = series.uncategorizedEpisodes.size, + episodes = series.uncategorizedEpisodes, + ) + seasonDao.upsert(sentinelSeason.toEntity()) + series.uncategorizedEpisodes.forEach { episode -> + episodeDao.upsert(episode.toEntity()) + } + } } } @@ -94,6 +113,19 @@ class OfflineRoomMediaLocalDataSource( seriesDao.getById(episode.seriesId) ?: throw RuntimeException("Cannot add episode without series. Episode: $episode") + if (episode.seasonId == UNCATEGORIZED_SEASON_ID && seasonDao.getById(UNCATEGORIZED_SEASON_ID) == null) { + seasonDao.upsert( + SeasonEntity( + id = UNCATEGORIZED_SEASON_ID, + seriesId = episode.seriesId, + name = UNCATEGORIZED_LABEL, + index = 0, + unwatchedEpisodeCount = 0, + episodeCount = 0, + ) + ) + } + episodeDao.upsert(episode.toEntity()) } } @@ -165,12 +197,14 @@ class OfflineRoomMediaLocalDataSource( } suspend fun getSeasons(seriesId: UUID): List { - return seasonDao.getBySeriesId(seriesId).map { seasonEntity -> - val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity -> - episodeEntity.toDomain() + return seasonDao.getBySeriesId(seriesId) + .filter { it.id != UNCATEGORIZED_SEASON_ID } + .map { seasonEntity -> + val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity -> + episodeEntity.toDomain() + } + seasonEntity.toDomain(episodes) } - seasonEntity.toDomain(episodes) - } } suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? { @@ -277,18 +311,23 @@ class OfflineRoomMediaLocalDataSource( cast = emptyList() ) - private fun SeriesEntity.toDomain(seasons: List) = Series( - id = id, - libraryId = libraryId, - name = name, - synopsis = synopsis, - year = year, - imageUrlPrefix = imageUrlPrefix, - unwatchedEpisodeCount = unwatchedEpisodeCount, - seasonCount = seasonCount, - seasons = seasons, - cast = emptyList() - ) + private fun SeriesEntity.toDomain(seasons: List): Series { + val (realSeasons, sentinelSeason) = seasons.partition { it.id != UNCATEGORIZED_SEASON_ID } + val uncategorizedEpisodes = sentinelSeason.flatMap { it.episodes } + return Series( + id = id, + libraryId = libraryId, + name = name, + synopsis = synopsis, + year = year, + imageUrlPrefix = imageUrlPrefix, + unwatchedEpisodeCount = unwatchedEpisodeCount, + seasonCount = seasonCount, + seasons = realSeasons, + uncategorizedEpisodes = uncategorizedEpisodes, + cast = emptyList(), + ) + } private fun SeasonEntity.toDomain(episodes: List) = Season( id = id,