mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user