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) { 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

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

View File

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

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
} }
@@ -145,8 +153,16 @@ class InMemoryLocalMediaRepository @Inject constructor(
series = seriesState.first()[seriesId] ?: throw RuntimeException("Series not found") series = seriesState.first()[seriesId] ?: throw RuntimeException("Series not found")
} }
val existingSeasonsById = series.seasons.associateBy { it.id }
val updatedSeries = series.copy( 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) } seriesState.update { it + (updatedSeries.id to updatedSeries) }
} catch (error: CancellationException) { } catch (error: CancellationException) {
@@ -160,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) {
@@ -179,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) {
@@ -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) { 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,12 +197,14 @@ 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)
val episodes = episodeDao.getBySeasonId(seasonEntity.id).map { episodeEntity -> .filter { it.id != UNCATEGORIZED_SEASON_ID }
episodeEntity.toDomain() .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? { suspend fun getEpisode(seriesId: UUID, seasonId: UUID, episodeId: UUID): Episode? {
@@ -277,18 +311,23 @@ class OfflineRoomMediaLocalDataSource(
cast = emptyList() cast = emptyList()
) )
private fun SeriesEntity.toDomain(seasons: List<Season>) = Series( private fun SeriesEntity.toDomain(seasons: List<Season>): Series {
id = id, val (realSeasons, sentinelSeason) = seasons.partition { it.id != UNCATEGORIZED_SEASON_ID }
libraryId = libraryId, val uncategorizedEpisodes = sentinelSeason.flatMap { it.episodes }
name = name, return Series(
synopsis = synopsis, id = id,
year = year, libraryId = libraryId,
imageUrlPrefix = imageUrlPrefix, name = name,
unwatchedEpisodeCount = unwatchedEpisodeCount, synopsis = synopsis,
seasonCount = seasonCount, year = year,
seasons = seasons, imageUrlPrefix = imageUrlPrefix,
cast = emptyList() unwatchedEpisodeCount = unwatchedEpisodeCount,
) seasonCount = seasonCount,
seasons = realSeasons,
uncategorizedEpisodes = uncategorizedEpisodes,
cast = emptyList(),
)
}
private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season( private fun SeasonEntity.toDomain(episodes: List<Episode>) = Season(
id = id, id = id,

View File

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