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) { var selectedSeasonId by remember(series.id, focusedSeasonId) {
mutableStateOf(defaultSeason?.id) 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 initialFocusSeasonId = defaultSeason?.id
val initialFocusSeason = initialFocusSeasonId?.let { seasonId -> val initialFocusSeason = initialFocusSeasonId?.let { seasonId ->
series.seasons.firstOrNull { it.id == seasonId } series.allSeasons.firstOrNull { it.id == seasonId }
} ?: defaultSeason } ?: defaultSeason
val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId) val initialFocusedEpisodeId = initialFocusSeason?.focusTargetEpisodeId(focusedEpisodeId)
val seasonTabFocusRequester = remember { FocusRequester() } val seasonTabFocusRequester = remember { FocusRequester() }
@@ -120,7 +120,7 @@ internal fun TvSeriesScreenContent(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (selectedSeason != null) { if (selectedSeason != null) {
TvSeasonTabs( TvSeasonTabs(
seasons = series.seasons, seasons = series.allSeasons,
selectedSeason = selectedSeason, selectedSeason = selectedSeason,
selectedItemFocusRequester = seasonTabFocusRequester, selectedItemFocusRequester = seasonTabFocusRequester,
firstItemTestTag = SeriesFirstSeasonTabTag, firstItemTestTag = SeriesFirstSeasonTabTag,
@@ -156,10 +156,10 @@ internal fun TvSeriesScreenContent(
private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? { private fun Series.defaultSeason(focusedSeasonId: UUID?): Season? {
if (focusedSeasonId != null) { 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? { private fun Season.nextUpEpisode(): Episode? {

View File

@@ -149,12 +149,12 @@ private fun SeriesScreenInternal(
var showMarkAsWatchedDialog by remember { mutableStateOf(false) } var showMarkAsWatchedDialog by remember { mutableStateOf(false) }
fun getDefaultSeason(): Season? { 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) } var selectedSeasonId by remember(series.id) { mutableStateOf(getDefaultSeason()?.id) }
val selectedSeason = val selectedSeason =
series.seasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason() series.allSeasons.firstOrNull { it.id == selectedSeasonId } ?: getDefaultSeason()
val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched } val nextUpEpisode = selectedSeason?.episodes?.firstOrNull { !it.watched }
?: selectedSeason?.episodes?.firstOrNull() ?: selectedSeason?.episodes?.firstOrNull()
val playAction = remember(nextUpEpisode, offline) { val playAction = remember(nextUpEpisode, offline) {
@@ -210,7 +210,7 @@ private fun SeriesScreenInternal(
) { _modifier -> ) { _modifier ->
if (selectedSeason != null) { if (selectedSeason != null) {
SeasonTabs( SeasonTabs(
seasons = series.seasons, seasons = series.allSeasons,
selectedSeason = selectedSeason, selectedSeason = selectedSeason,
onSelect = { selectedSeasonId = it.id }, onSelect = { selectedSeasonId = it.id },
modifier = _modifier modifier = _modifier

View File

@@ -2,6 +2,10 @@ package hu.bbara.purefin.model
import java.util.UUID 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( data class Series(
val id: UUID, val id: UUID,
val libraryId: UUID, val libraryId: UUID,
@@ -12,5 +16,27 @@ data class Series(
val unwatchedEpisodeCount: Int, val unwatchedEpisodeCount: Int,
val seasonCount: Int, val seasonCount: Int,
val seasons: List<Season>, val seasons: List<Season>,
val uncategorizedEpisodes: List<Episode> = emptyList(),
val cast: List<CastMember> 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.concurrency.SingleFlight
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
import hu.bbara.purefin.core.data.UserSessionRepository 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.toEpisode
import hu.bbara.purefin.data.converter.toMovie import hu.bbara.purefin.data.converter.toMovie
import hu.bbara.purefin.data.converter.toSeason 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.Episode
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import hu.bbara.purefin.model.UNCATEGORIZED_SEASON_ID
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -82,7 +84,10 @@ class InMemoryLocalMediaRepository @Inject constructor(
seriesState.update { current -> seriesState.update { current ->
val existing = current[series.id] val existing = current[series.id]
val merged = if (existing != null && existing.seasons.isNotEmpty() && series.seasons.isEmpty()) { 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 { } else {
series series
} }
@@ -124,7 +129,10 @@ class InMemoryLocalMediaRepository @Inject constructor(
current + series.associateBy { it.id }.mapValues { (id, newSeries) -> current + series.associateBy { it.id }.mapValues { (id, newSeries) ->
val existing = current[id] val existing = current[id]
if (existing != null && existing.seasons.isNotEmpty() && newSeries.seasons.isEmpty()) { if (existing != null && existing.seasons.isNotEmpty() && newSeries.seasons.isEmpty()) {
newSeries.copy(seasons = existing.seasons) newSeries.copy(
seasons = existing.seasons,
uncategorizedEpisodes = existing.uncategorizedEpisodes,
)
} else { } else {
newSeries newSeries
} }
@@ -168,6 +176,11 @@ class InMemoryLocalMediaRepository @Inject constructor(
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) = override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) =
singleFlight.run("LocalMedia:loadSeasonEpisodes:$seriesId:$seasonId") { singleFlight.run("LocalMedia:loadSeasonEpisodes:$seriesId:$seasonId") {
try { 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 // Ensure the series and season is loaded
var series = seriesState.value[seriesId] var series = seriesState.value[seriesId]
if (series == null) { if (series == null) {
@@ -187,22 +200,35 @@ class InMemoryLocalMediaRepository @Inject constructor(
} }
val serverUrl = userSessionRepository.serverUrl.first() val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId) val seriesName = seriesState.value[seriesId]?.name
.map { it.toEpisode(serverUrl) } 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 -> seriesState.update { current ->
val currentSeries = current[seriesId] ?: return@update current val currentSeries = current[seriesId] ?: return@update current
val updatedSeries = currentSeries.copy( val updatedSeries = currentSeries.copy(
seasons = currentSeries.seasons.map { seasons = currentSeries.seasons.map {
if (it.id == seasonId && it.episodes.isEmpty()) { if (it.id == seasonId && it.episodes.isEmpty()) {
it.copy(episodes = episodes) it.copy(episodes = categorizedEpisodes)
} else { } else {
it it
} }
} },
uncategorizedEpisodes = (
currentSeries.uncategorizedEpisodes + uncategorizedEpisodes
).distinctBy { it.id }
) )
current + (updatedSeries.id to updatedSeries) 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) { } catch (error: CancellationException) {
throw error throw error
} catch (error: Exception) { } 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) { if (changed) {
current + (series.id to series.copy(seasons = seasons)) current + (series.id to series.copy(
seasons = seasons,
uncategorizedEpisodes = uncategorizedEpisodes,
))
} else { } else {
current current
} }

View File

@@ -9,12 +9,15 @@ import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series 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.BaseItemDto
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber import timber.log.Timber
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
fun BaseItemDto.toLibrary(serverUrl: String): Library? { 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 releaseDate = formatReleaseDate(premiereDate, productionYear)
val imageUrlPrefix = id?.let { itemId -> val imageUrlPrefix = id?.let { itemId ->
ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId) ImageUrlBuilder.toPrefixImageUrl(url = serverUrl, itemId = itemId)
} ?: "" } ?: ""
return Episode( return Episode(
id = id, id = id ?: error("Episode without id"),
seriesId = seriesId!!, seriesId = seriesId ?: fallbackSeriesId ?: UNCATEGORIZED_SERIES_ID,
seriesName = seriesName!!, seriesName = seriesName ?: fallbackSeriesName ?: "Unknown",
seasonId = parentId!!, seasonId = parentId ?: UNCATEGORIZED_SEASON_ID,
seasonIndex = parentIndexNumber!!, seasonIndex = parentIndexNumber ?: 0,
title = name ?: "Unknown title", title = name ?: "Unknown title",
index = indexNumber!!, index = indexNumber ?: 0,
releaseDate = releaseDate, releaseDate = releaseDate,
rating = officialRating ?: "NR", rating = officialRating ?: "NR",
runtime = formatRuntime(runTimeTicks), runtime = formatRuntime(runTimeTicks),
progress = userData!!.playedPercentage, progress = userData?.playedPercentage,
watched = userData!!.played, watched = userData?.played ?: false,
format = container?.uppercase() ?: "VIDEO", format = container?.uppercase() ?: "VIDEO",
synopsis = overview ?: "No synopsis available.", synopsis = overview ?: "No synopsis available.",
imageUrlPrefix = imageUrlPrefix, 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.Movie
import hu.bbara.purefin.model.Season import hu.bbara.purefin.model.Season
import hu.bbara.purefin.model.Series 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.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.util.UUID import java.util.UUID
@@ -78,6 +80,23 @@ class OfflineRoomMediaLocalDataSource(
episodeDao.upsert(episode.toEntity()) 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) seriesDao.getById(episode.seriesId)
?: throw RuntimeException("Cannot add episode without series. Episode: $episode") ?: 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()) episodeDao.upsert(episode.toEntity())
} }
} }
@@ -165,7 +197,9 @@ class OfflineRoomMediaLocalDataSource(
} }
suspend fun getSeasons(seriesId: UUID): List<Season> { suspend fun getSeasons(seriesId: UUID): List<Season> {
return seasonDao.getBySeriesId(seriesId).map { seasonEntity -> return seasonDao.getBySeriesId(seriesId)
.filter { it.id != UNCATEGORIZED_SEASON_ID }
.map { seasonEntity ->
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity -> val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity ->
episodeEntity.toDomain() episodeEntity.toDomain()
} }
@@ -277,7 +311,10 @@ class OfflineRoomMediaLocalDataSource(
cast = emptyList() cast = emptyList()
) )
private fun SeriesEntity.toDomain(seasons: List<Season>) = Series( 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, id = id,
libraryId = libraryId, libraryId = libraryId,
name = name, name = name,
@@ -286,9 +323,11 @@ class OfflineRoomMediaLocalDataSource(
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix,
unwatchedEpisodeCount = unwatchedEpisodeCount, unwatchedEpisodeCount = unwatchedEpisodeCount,
seasonCount = seasonCount, seasonCount = seasonCount,
seasons = seasons, seasons = realSeasons,
cast = emptyList() uncategorizedEpisodes = uncategorizedEpisodes,
cast = emptyList(),
) )
}
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season( private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
id = id, id = id,