Compare commits

..

6 Commits

Author SHA1 Message Date
660b50d7cd chore(build): increment app-tv versionCode to 2000023 2026-07-22 20:41:43 +02:00
7472b35d66 chore(build): increment app versionCode to 1000037 2026-07-22 20:39:20 +02:00
d8de27c972 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
2026-07-22 18:36:02 +00:00
e33a92271a chore(build): increment app versionCode to 1000036 2026-07-21 19:06:37 +02:00
869bb27497 feat(player): add sticky auto-brightness zone with hysteresis
Refactor brightness drag to use a normal 0-1 sliding range with a
sticky auto-brightness zone at the bottom. Enter/exit auto mode via
hysteresis thresholds (15% overshoot past 0%) so accidental drags
don't toggle the mode.

Lock the current system brightness on screen open (reading the actual
value even when in auto mode) and restore the original window brightness
on ON_STOP.

Add vertical drag lifecycle callbacks (start/end) to PlayerGesturesLayer
so overshoot can be reset per gesture. Extract drag handlers into local
functions for readability.
2026-07-21 17:01:35 +00:00
a4bb57822a fix(catalog): preserve cached episodes when refreshing seasons 2026-07-21 16:46:33 +00:00
9 changed files with 281 additions and 75 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

@@ -3,6 +3,7 @@ package hu.bbara.purefin.ui.screen.player
import android.app.Activity
import android.content.Context
import android.media.AudioManager
import android.provider.Settings
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -67,8 +68,9 @@ import kotlin.math.abs
import kotlin.math.roundToInt
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
/** Small negative band below zero that represents adaptive/auto brightness mode. */
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
private const val BRIGHTNESS_DRAG_SENSITIVITY = 800f
private const val BRIGHTNESS_AUTO_ENTER_THRESHOLD = 0.15f
private const val BRIGHTNESS_AUTO_EXIT_THRESHOLD = 0.15f
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
@OptIn(UnstableApi::class)
@@ -90,7 +92,11 @@ fun PlayerScreen(
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) }
var volume by remember { mutableFloatStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat()) }
var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity)) }
var brightness by remember { mutableFloatStateOf(readCurrentBrightness(activity, context)) }
var isAutoBrightness by remember { mutableStateOf(false) }
var dragOvershoot by remember { mutableFloatStateOf(0f) }
var autoDragTick by remember { mutableStateOf(0) }
val originalScreenBrightness by remember { mutableFloatStateOf(activity?.window?.attributes?.screenBrightness ?: -1f) }
var showQueuePanel by remember { mutableStateOf(false) }
var horizontalSeekFeedback by remember { mutableStateOf<Long?>(null) }
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
@@ -131,9 +137,61 @@ fun PlayerScreen(
toggleControlsVisibility()
hideControlsWithTimeout()
}
fun onBrightnessDragStart(isLeftSide: Boolean) {
if (isLeftSide) {
dragOvershoot = 0f
}
}
fun onBrightnessDragEnd() {
dragOvershoot = 0f
}
fun onBrightnessDrag(delta: Float) {
val diff = -delta / BRIGHTNESS_DRAG_SENSITIVITY
if (isAutoBrightness) {
if (diff > 0f) {
dragOvershoot += diff
if (dragOvershoot >= BRIGHTNESS_AUTO_EXIT_THRESHOLD) {
isAutoBrightness = false
brightness = (dragOvershoot - BRIGHTNESS_AUTO_EXIT_THRESHOLD).coerceIn(0f, 1f)
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = false)
}
} else {
autoDragTick++
}
} else {
val newBrightness = brightness + diff
if (newBrightness <= 0f) {
brightness = 0f
applyBrightness(activity, brightness, isAuto = false)
dragOvershoot += newBrightness
if (dragOvershoot <= -BRIGHTNESS_AUTO_ENTER_THRESHOLD) {
isAutoBrightness = true
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = true)
}
} else {
brightness = newBrightness.coerceIn(0f, 1f)
dragOvershoot = 0f
applyBrightness(activity, brightness, isAuto = false)
}
}
}
fun onVolumeDrag(delta: Float) {
val diff = -delta / 800f
volume = (volume + diff).coerceIn(0f, 1f)
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
(volume * maxVolume).roundToInt(),
0
)
}
LifecycleEventEffect(Lifecycle.Event.ON_START) {
hideControlsWithTimeout()
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
restoreBrightness(activity, originalScreenBrightness)
}
Box(
@@ -169,20 +227,10 @@ fun PlayerScreen(
onDoubleTapRight = { viewModel.seekBy(30_000) },
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
onDoubleTapCenter = { viewModel.togglePlayPause() },
onVerticalDragLeft = { delta ->
val diff = (-delta / 800f)
brightness = (brightness + diff).coerceIn(AUTO_BRIGHTNESS_THRESHOLD, 1f)
applyBrightness(activity, brightness)
},
onVerticalDragRight = { delta ->
val diff = (-delta / 800f)
volume = (volume + diff).coerceIn(0f, 1f)
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
(volume * maxVolume).roundToInt(),
0
)
},
onVerticalDragStart = ::onBrightnessDragStart,
onVerticalDragEnd = ::onBrightnessDragEnd,
onVerticalDragLeft = ::onBrightnessDrag,
onVerticalDragRight = ::onVolumeDrag,
onHorizontalDragPreview = { deltaMs, previewPositionMs ->
horizontalSeekFeedback = deltaMs
horizontalSeekPreviewPositionMs = previewPositionMs?.let {
@@ -230,23 +278,21 @@ fun PlayerScreen(
}
ValueChangeTimedVisibility(
value = brightness,
value = BrightnessUiState(brightness, isAutoBrightness, autoDragTick),
hideAfterMillis = 800,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 20.dp)
) { currentBrightness ->
) { state ->
PlayerAdjustmentIndicator(
modifier = Modifier.align(Alignment.Center),
icon = Icons.Outlined.BrightnessMedium,
contentDescription = "Brightness",
value = currentBrightness,
bottomText = if (currentBrightness <= AUTO_BRIGHTNESS_THRESHOLD) {
value = state.brightness,
bottomText = if (state.isAuto) {
"Auto"
} else if (currentBrightness >= 0f) {
"${(currentBrightness * 100).roundToInt()}%"
} else {
null
"${(state.brightness * 100).roundToInt()}%"
}
)
}
@@ -434,14 +480,34 @@ private fun formatSeekDelta(deltaMs: Long): String {
}
}
private fun readCurrentBrightness(activity: Activity?): Float {
val current = activity?.window?.attributes?.screenBrightness
return if (current != null && current >= 0) current else -1f
private data class BrightnessUiState(val brightness: Float, val isAuto: Boolean, val dragTick: Int)
private fun readCurrentBrightness(activity: Activity?, context: Context): Float {
val windowBrightness = activity?.window?.attributes?.screenBrightness
if (windowBrightness != null && windowBrightness >= 0f) {
return windowBrightness
}
return try {
val systemBrightness = Settings.System.getInt(
context.contentResolver,
Settings.System.SCREEN_BRIGHTNESS
)
(systemBrightness / 255f).coerceIn(0f, 1f)
} catch (e: Settings.SettingNotFoundException) {
0.5f
}
}
private fun applyBrightness(activity: Activity?, value: Float) {
private fun applyBrightness(activity: Activity?, brightness: Float, isAuto: Boolean) {
activity ?: return
val params = activity.window.attributes
params.screenBrightness = if (value <= AUTO_BRIGHTNESS_THRESHOLD) -1f else value
params.screenBrightness = if (isAuto) -1f else brightness
activity.window.attributes = params
}
private fun restoreBrightness(activity: Activity?, originalValue: Float) {
activity ?: return
val params = activity.window.attributes
params.screenBrightness = originalValue
activity.window.attributes = params
}

View File

@@ -30,6 +30,8 @@ fun PlayerGesturesLayer(
onDoubleTapLeft: () -> Unit,
onVerticalDragLeft: (delta: Float) -> Unit,
onVerticalDragRight: (delta: Float) -> Unit,
onVerticalDragStart: (isLeftSide: Boolean) -> Unit = {},
onVerticalDragEnd: () -> Unit = {},
onHorizontalDragPreview: (deltaMs: Long?, previewPositionMs: Long?) -> Unit = { _, _ -> },
onHorizontalDragSeekTo: (positionMs: Long) -> Unit,
currentPositionProvider: () -> Long,
@@ -79,6 +81,7 @@ fun PlayerGesturesLayer(
var isHorizontalDragActive = false
var lastPreviewDelta: Long? = null
var startPositionMs = 0L
var verticalDragStarted = false
drag(down.id) { change ->
val delta = change.positionChange()
@@ -115,6 +118,10 @@ fun PlayerGesturesLayer(
DragDirection.VERTICAL -> {
change.consume()
val isLeftSide = startX < size.width / 2
if (!verticalDragStarted) {
verticalDragStarted = true
onVerticalDragStart(isLeftSide)
}
if (isLeftSide) {
onVerticalDragLeft(delta.y)
} else {
@@ -134,6 +141,10 @@ fun PlayerGesturesLayer(
}
}
onHorizontalDragPreview(null, null)
if (verticalDragStarted) {
onVerticalDragEnd()
}
}
}
)

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
}
@@ -145,8 +153,16 @@ class InMemoryLocalMediaRepository @Inject constructor(
series = seriesState.first()[seriesId] ?: throw RuntimeException("Series not found")
}
val existingSeasonsById = series.seasons.associateBy { it.id }
val updatedSeries = series.copy(
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }.map { fresh ->
val existing = existingSeasonsById[fresh.id]
if (existing != null && existing.episodes.isNotEmpty()) {
fresh.copy(episodes = existing.episodes)
} else {
fresh
}
}
)
seriesState.update { it + (updatedSeries.id to updatedSeries) }
} catch (error: CancellationException) {
@@ -160,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) {
@@ -179,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) {
@@ -277,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,

View File

@@ -28,6 +28,6 @@ android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.builtInKotlin=false
android.newDsl=false
purefinReleaseVersionCode=1000035
purefinReleaseVersionCode=1000037
purefinDebugVersionCode=1000003
purefinTvVersionCode=2000022
purefinTvVersionCode=2000023