feat(series): surface uncategorized episodes as synthetic season

Episodes missing season/identity fields from Jellyfin previously crashed
the converter due to non-null assertions. Introduce an Uncategorized
sentinel season appended to a series via Series.allSeasons so these
episodes are selectable in the season tabs on both phone and TV.

- Add UNCATEGORIZED_SEASON_ID/SERIES_ID/LABEL constants in core-model
- Make BaseItemDtoConverter null-safe and partition uncategorized episodes
- InMemoryLocalMediaRepository collects uncategorized episodes per series
- OfflineRoomMediaLocalDataSource persists and reconstructs the sentinel
- SeriesScreen and TvSeriesScreen use allSeasons for tab rendering
This commit is contained in:
2026-07-22 18:36:02 +00:00
parent e33a92271a
commit d8de27c972
6 changed files with 164 additions and 43 deletions

View File

@@ -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? {

View File

@@ -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

View File

@@ -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<Season>,
val uncategorizedEpisodes: List<Episode> = emptyList(),
val cast: List<CastMember>
)
) {
/**
* 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<Season>
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,
)
}
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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<Season> {
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<Season>) = 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<Season>): 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<Episode>) = Season(
id = id,