mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Compare commits
19 Commits
53a7d4ad23
...
c2cc4e9e65
| Author | SHA1 | Date | |
|---|---|---|---|
| c2cc4e9e65 | |||
| 13bc9410b4 | |||
| 422642ad19 | |||
| 59315b701d | |||
| 3c46650a63 | |||
| f0a7828613 | |||
| 8c758e8262 | |||
| 9872f401f8 | |||
| 86f44ac9ce | |||
| d80b283ded | |||
| 1ac649e8ae | |||
| b23db4bab3 | |||
| 67a141e7d7 | |||
| 9f432edd27 | |||
| 80b1bd8547 | |||
| 00c5689195 | |||
| bd2c1777a5 | |||
| a5d04998ac | |||
| da89a8a661 |
@@ -1,6 +1,6 @@
|
||||
package hu.bbara.purefin.ui.screen.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -10,11 +10,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.LibraryUiModel
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
|
||||
import hu.bbara.purefin.ui.screen.home.components.TvHomeHeroBackdrop
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
@@ -30,11 +31,13 @@ fun TvHomeScreen(
|
||||
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
modifier = modifier.fillMaxSize(),
|
||||
color = scheme.background
|
||||
) {
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
TvHomeHeroBackdrop(
|
||||
backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
@@ -57,3 +60,4 @@ fun TvHomeScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -16,6 +14,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -31,40 +30,34 @@ internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
|
||||
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"
|
||||
|
||||
private const val TvHomeHeroAnimationMillis = 180
|
||||
// Half the screen for the billboard, half for the content rows. Tuned so both
|
||||
// the hero block and the first focused row fit on a 540dp-tall (1080p xhdpi) TV.
|
||||
internal const val TvHomeHeroHeightFraction = 0.5f
|
||||
// The billboard backdrop covers the top portion of the home screen so the hero
|
||||
// text sits at the bottom of the backdrop and the content rows begin below it.
|
||||
internal const val TvHomeHeroHeightFraction = 0.45f
|
||||
internal const val TvHomeBackdropOffset = 0.20f
|
||||
|
||||
/**
|
||||
* The home screen background image plus the darkening gradients that keep the
|
||||
* hero text readable. Drawn behind the home content and sized to the top
|
||||
* [TvHomeHeroHeightFraction] of the screen.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TvFocusedItemHero(
|
||||
item: MediaUiModel,
|
||||
internal fun TvHomeHeroBackdrop(
|
||||
backdropImageUrl: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
heightFraction: Float = TvHomeHeroHeightFraction,
|
||||
heightFraction: Float = TvHomeHeroHeightFraction + TvHomeBackdropOffset,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
Crossfade(
|
||||
targetState = backdropImageUrl,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-backdrop",
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(heightFraction)
|
||||
.background(scheme.background)
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = item.backdropImageUrl,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-background"
|
||||
) { imageUrl ->
|
||||
PurefinAsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
// Darken the left side so the text block stays readable over any backdrop.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
drawRect(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0.86f),
|
||||
@@ -74,12 +67,8 @@ internal fun TvFocusedItemHero(
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
// Strong bottom fade so the title and metadata are legible.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to scheme.background.copy(alpha = 0f),
|
||||
@@ -89,11 +78,31 @@ internal fun TvFocusedItemHero(
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
) { imageUrl ->
|
||||
PurefinAsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvFocusedItemHero(
|
||||
item: MediaUiModel,
|
||||
modifier: Modifier = Modifier,
|
||||
heightFraction: Float = TvHomeHeroHeightFraction,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Crossfade(
|
||||
targetState = item,
|
||||
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
|
||||
label = "tv-home-hero-content"
|
||||
label = "tv-home-hero-content",
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(heightFraction)
|
||||
) { hero ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
@@ -130,7 +139,6 @@ internal fun TvFocusedItemHero(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvHomeHeroMetadataRow(item: MediaUiModel) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
package hu.bbara.purefin.ui.screen.home.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -42,7 +40,6 @@ fun TvHomeContent(
|
||||
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
|
||||
val hasContinueWatching = continueWatching.isNotEmpty()
|
||||
val hasNextUp = nextUp.isNotEmpty()
|
||||
@@ -65,7 +62,6 @@ fun TvHomeContent(
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.focusRequester(initialFocusRequester),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
contentPadding = contentPadding
|
||||
|
||||
@@ -23,10 +23,10 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -72,6 +73,9 @@ import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvPlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
|
||||
import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -308,9 +312,6 @@ fun TvPlayerScreen(
|
||||
onSeekRelative = { deltaMs ->
|
||||
viewModel.seekBy(deltaMs)
|
||||
},
|
||||
onSeekLiveEdge = {
|
||||
viewModel.seekToLiveEdge()
|
||||
},
|
||||
onSkipSegment = {
|
||||
viewModel.skipActiveSegment()
|
||||
},
|
||||
@@ -330,6 +331,17 @@ fun TvPlayerScreen(
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
TvPlayerClock()
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showSkipIntroButton,
|
||||
modifier = Modifier
|
||||
@@ -367,6 +379,7 @@ fun TvPlayerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (!showSkipIntroButton) {
|
||||
ValueChangeTimedVisibility(
|
||||
value = hiddenSeekCounter,
|
||||
hideAfterMillis = 2500L,
|
||||
@@ -382,6 +395,7 @@ fun TvPlayerScreen(
|
||||
adMarkers = uiState.ads
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TvPlayerLoadingErrorEndCard(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
@@ -400,7 +414,7 @@ fun TvPlayerScreen(
|
||||
hideAfterMillis = TV_HIDDEN_STOP_FEEDBACK_MS,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
TvPlayerResumeStopFeedback(resume = uiState.isPlaying)
|
||||
TvPlayerStopFeedback(stopped = !uiState.isPlaying)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
@@ -464,6 +478,25 @@ private fun HiddenTvSeekTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvPlayerClock(modifier: Modifier = Modifier) {
|
||||
var currentTime by remember { mutableStateOf(LocalTime.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
currentTime = LocalTime.now()
|
||||
delay(1_000L)
|
||||
}
|
||||
}
|
||||
val formatter = remember { DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) }
|
||||
Text(
|
||||
text = formatter.format(currentTime),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
internal fun handleTvPlayerRootKeyEvent(
|
||||
event: KeyEvent,
|
||||
controlsVisible: Boolean,
|
||||
@@ -535,10 +568,13 @@ internal fun handleTvPlayerRootKeyEvent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TvPlayerResumeStopFeedback(
|
||||
resume: Boolean,
|
||||
internal fun TvPlayerStopFeedback(
|
||||
stopped: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (!stopped) {
|
||||
return
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.testTag(TvPlayerHiddenStopFeedbackTag)
|
||||
@@ -548,7 +584,7 @@ internal fun TvPlayerResumeStopFeedback(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (resume) Icons.Outlined.PlayArrow else Icons.Outlined.Pause,
|
||||
imageVector = Icons.Outlined.Pause,
|
||||
contentDescription = "Play/Pause playback",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(72.dp)
|
||||
|
||||
@@ -16,10 +16,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Forward30
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Replay10
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material.icons.outlined.SkipPrevious
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -63,7 +61,6 @@ internal fun TvPlayerControlsOverlay(
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onSeekLiveEdge: () -> Unit,
|
||||
onSkipSegment: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
@@ -109,7 +106,6 @@ internal fun TvPlayerControlsOverlay(
|
||||
onPlayPause = onPlayPause,
|
||||
onSeek = onSeek,
|
||||
onSeekRelative = onSeekRelative,
|
||||
onSeekLiveEdge = onSeekLiveEdge,
|
||||
onSkipSegment = onSkipSegment,
|
||||
onNext = onNext,
|
||||
onPrevious = onPrevious,
|
||||
@@ -172,7 +168,6 @@ private fun TvPlayerBottomSection(
|
||||
onPlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onSeekRelative: (Long) -> Unit,
|
||||
onSeekLiveEdge: () -> Unit,
|
||||
onSkipSegment: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
@@ -247,13 +242,6 @@ private fun TvPlayerBottomSection(
|
||||
size = 56,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Replay10,
|
||||
contentDescription = "Seek backward 10 seconds",
|
||||
onClick = { onSeekRelative(-10_000) },
|
||||
size = 56,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
TvIconButton(
|
||||
icon = if (uiState.isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow,
|
||||
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
|
||||
@@ -263,13 +251,6 @@ private fun TvPlayerBottomSection(
|
||||
.focusRequester(focusRequester)
|
||||
.testTag(TvPlayerPlayPauseButtonTag)
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.Forward30,
|
||||
contentDescription = "Seek forward 30 seconds",
|
||||
onClick = { onSeekRelative(30_000) },
|
||||
size = 56,
|
||||
modifier = expandPlaylistModifier
|
||||
)
|
||||
TvIconButton(
|
||||
icon = Icons.Outlined.SkipNext,
|
||||
contentDescription = "Next",
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -33,7 +34,16 @@ fun PlayerSeekBar(
|
||||
val currentPosition = positionMs.coerceIn(0, safeDuration)
|
||||
var sliderPosition by remember { mutableFloatStateOf(currentPosition.toFloat()) }
|
||||
var isScrubbing by remember { mutableStateOf(false) }
|
||||
val sliderValue = if (isScrubbing) sliderPosition else currentPosition.toFloat()
|
||||
val sliderValue = sliderPosition
|
||||
|
||||
// Keep sliderPosition in sync with the player's position when the user is not
|
||||
// actively scrubbing. While scrubbing, the local drag value is preserved so the
|
||||
// thumb does not snap back to a stale positionMs prop before the seek is committed.
|
||||
LaunchedEffect(positionMs) {
|
||||
if (!isScrubbing) {
|
||||
sliderPosition = positionMs.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -64,6 +74,7 @@ fun PlayerSeekBar(
|
||||
},
|
||||
onValueChangeFinished = {
|
||||
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
|
||||
sliderPosition = targetPosition.toFloat()
|
||||
isScrubbing = false
|
||||
onSeek(targetPosition)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package hu.bbara.purefin.core.concurrency
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Coalesces concurrent and near-time-repeated suspend calls keyed by [key].
|
||||
*
|
||||
* Within a single [run] with a given [key]:
|
||||
* - If a call with the same [key] is already in flight, the new caller
|
||||
* awaits the same [Deferred] and receives the first caller's result.
|
||||
* - If a call with the same [key] completed successfully less than
|
||||
* [TTL_MS] ago, the cached result is returned without re-executing
|
||||
* [block].
|
||||
* - Otherwise [block] is executed, its result is cached for [TTL_MS]
|
||||
* and shared with any concurrent awaiters.
|
||||
*
|
||||
* Failures are never cached: a thrown exception (other than
|
||||
* [CancellationException]) is propagated to all awaiters of the
|
||||
* in-flight call, but a subsequent call with the same key will execute
|
||||
* [block] again immediately.
|
||||
*
|
||||
* [CancellationException] is always re-thrown so coroutine cancellation
|
||||
* semantics are preserved.
|
||||
*/
|
||||
@Singleton
|
||||
class SingleFlight @Inject constructor() {
|
||||
private val inFlight: MutableMap<String, Deferred<*>> = ConcurrentHashMap()
|
||||
private val cache: MutableMap<String, CacheEntry<*>> = ConcurrentHashMap()
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun <T> run(key: String, block: suspend () -> T): T {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cached = cache[key] as? CacheEntry<T>
|
||||
if (cached != null && now - cached.timestamp < TTL_MS) {
|
||||
return cached.value
|
||||
}
|
||||
|
||||
val resolution: Resolution<T> = mutex.withLock {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cachedNow = cache[key] as? CacheEntry<T>
|
||||
if (cachedNow != null && System.currentTimeMillis() - cachedNow.timestamp < TTL_MS) {
|
||||
return@withLock Resolution.Cached(cachedNow.value)
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val existing = inFlight[key] as? Deferred<T>
|
||||
if (existing != null) {
|
||||
return@withLock Resolution.Join(existing)
|
||||
}
|
||||
val newDeferred = CompletableDeferred<T>()
|
||||
inFlight[key] = newDeferred
|
||||
Resolution.Start(newDeferred)
|
||||
}
|
||||
|
||||
return when (resolution) {
|
||||
is Resolution.Cached -> resolution.value
|
||||
is Resolution.Join -> resolution.deferred.await()
|
||||
is Resolution.Start -> {
|
||||
val deferred = resolution.deferred
|
||||
try {
|
||||
val result = block()
|
||||
cache[key] = CacheEntry(result, System.currentTimeMillis())
|
||||
deferred.complete(result)
|
||||
result
|
||||
} catch (error: CancellationException) {
|
||||
deferred.completeExceptionally(error)
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
deferred.completeExceptionally(error)
|
||||
throw error
|
||||
} finally {
|
||||
inFlight.remove(key, deferred)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Resolution<T> {
|
||||
data class Cached<T>(val value: T) : Resolution<T>()
|
||||
data class Join<T>(val deferred: Deferred<T>) : Resolution<T>()
|
||||
data class Start<T>(val deferred: CompletableDeferred<T>) : Resolution<T>()
|
||||
}
|
||||
|
||||
private data class CacheEntry<T>(val value: T, val timestamp: Long)
|
||||
|
||||
companion object {
|
||||
const val TTL_MS: Long = 15_000L
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package hu.bbara.purefin.core.data
|
||||
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.model.Library
|
||||
import hu.bbara.purefin.model.Media
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -14,15 +13,4 @@ interface HomeRepository {
|
||||
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
|
||||
fun ensureReady()
|
||||
suspend fun refreshHomeData()
|
||||
|
||||
/**
|
||||
* Fetches the full content (movies or series) of a single library on
|
||||
* demand. Used by the library detail screen; the home page no longer
|
||||
* pays for full library content on every refresh.
|
||||
*
|
||||
* Returns `null` when the server returned 304 Not Modified for the
|
||||
* per-library ETag — the caller should keep its previously fetched
|
||||
* copy. An empty list is a legitimate "the library is empty" result.
|
||||
*/
|
||||
suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>?
|
||||
}
|
||||
|
||||
@@ -68,17 +68,16 @@ class AppViewModel @Inject constructor(
|
||||
|
||||
val libraries = homeRepository.libraries.map { libraries ->
|
||||
libraries.map {
|
||||
// The home page no longer fetches the full library content
|
||||
// (see InMemoryAppContentRepository.loadLibraries). isEmpty
|
||||
// therefore reflects the actual server-reported size, not
|
||||
// the contents of the local media repository.
|
||||
LibraryUiModel(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
type = it.type,
|
||||
posterUrl = it.posterUrl,
|
||||
size = it.size,
|
||||
isEmpty = it.size == 0,
|
||||
isEmpty = when (it.type) {
|
||||
LibraryKind.MOVIES -> localMediaRepository.movies.value.isEmpty()
|
||||
LibraryKind.SERIES -> localMediaRepository.series.value.isEmpty()
|
||||
}
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
@@ -6,13 +6,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||
import hu.bbara.purefin.core.navigation.Route
|
||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||
import hu.bbara.purefin.model.LibraryKind
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -26,35 +31,24 @@ class LibraryViewModel @Inject constructor(
|
||||
|
||||
private val selectedLibrary = MutableStateFlow<UUID?>(null)
|
||||
|
||||
// Local cache of the last-fetched content per library. Used to preserve
|
||||
// content when re-selecting a library whose ETag matches (so the
|
||||
// repository's 304 response can be treated as "use the cached copy").
|
||||
private val cachedContents = mutableMapOf<UUID, List<MediaUiModel>>()
|
||||
|
||||
private val _contents = MutableStateFlow<List<MediaUiModel>>(emptyList())
|
||||
val contents: StateFlow<List<MediaUiModel>> = _contents.asStateFlow()
|
||||
val contents: StateFlow<List<MediaUiModel>> = combine(selectedLibrary, homeRepository.libraries) {
|
||||
libraryId, libraries ->
|
||||
if (libraryId == null) {
|
||||
return@combine emptyList()
|
||||
}
|
||||
val library = libraries.find { it.id == libraryId } ?: return@combine emptyList()
|
||||
when (library.type) {
|
||||
LibraryKind.SERIES -> library.series!!.map { series ->
|
||||
SeriesUiModel(series)
|
||||
}
|
||||
LibraryKind.MOVIES -> library.movies!!.map { movie ->
|
||||
MovieUiModel(movie)
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
init {
|
||||
viewModelScope.launch { homeRepository.ensureReady() }
|
||||
viewModelScope.launch {
|
||||
selectedLibrary.collect { libraryId ->
|
||||
if (libraryId == null) {
|
||||
_contents.value = emptyList()
|
||||
} else {
|
||||
val fresh = homeRepository.loadLibraryContent(libraryId)
|
||||
if (fresh != null) {
|
||||
cachedContents[libraryId] = fresh
|
||||
_contents.value = fresh
|
||||
} else {
|
||||
// ETag 304 — keep the cached copy for this library if
|
||||
// we have one. On a first-visit 304 (rare, would
|
||||
// require the library's ETag to be set without any
|
||||
// prior fetch) the screen briefly shows empty.
|
||||
_contents.value = cachedContents[libraryId] ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onMovieSelected(movieId: UUID) {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package hu.bbara.purefin.core.jellyfin
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/**
|
||||
* Marks an `OkHttpClient` that is wired into the Jellyfin SDK's `OkHttpFactory`.
|
||||
* Distinct from the unqualified client used for image loading and media streaming
|
||||
* because the SDK has its own auth path and the ETag interceptor should not leak
|
||||
* into non-SDK HTTP calls.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class JellyfinSdkClient
|
||||
@@ -1,12 +1,10 @@
|
||||
package hu.bbara.purefin.data.catalog
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import hu.bbara.purefin.core.concurrency.SingleFlight
|
||||
import hu.bbara.purefin.core.data.HomeRepository
|
||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.model.MediaUiModel
|
||||
import hu.bbara.purefin.core.model.MovieUiModel
|
||||
import hu.bbara.purefin.core.model.SeriesUiModel
|
||||
import hu.bbara.purefin.data.converter.toEpisode
|
||||
import hu.bbara.purefin.data.converter.toLibrary
|
||||
import hu.bbara.purefin.data.converter.toMovie
|
||||
@@ -37,14 +35,14 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import timber.log.Timber
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import timber.log.Timber
|
||||
|
||||
@Singleton
|
||||
class InMemoryAppContentRepository @Inject constructor(
|
||||
@@ -53,6 +51,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private val homeCacheDataStore: DataStore<HomeCache>,
|
||||
private val onlineMediaRepository: InMemoryLocalMediaRepository,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val singleFlight: SingleFlight,
|
||||
) : HomeRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var cacheLoadJob: Job? = null
|
||||
@@ -76,17 +75,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
|
||||
|
||||
// dateLastMediaAdded values captured at the end of the last successful
|
||||
// refresh. Used to short-circuit per-library /Items/Latest calls when
|
||||
// the timestamp hasn't moved. Persisted via HomeCache.
|
||||
private val cachedLibraryDateLastMediaAdded = mutableMapOf<UUID, String>()
|
||||
|
||||
// dateLastMediaAdded values observed during the CURRENT refresh's
|
||||
// /UserViews response. Read by loadLatestLibraryContent to decide
|
||||
// which libraries need a /Items/Latest call, then merged into
|
||||
// cachedLibraryDateLastMediaAdded and persisted at the end.
|
||||
private var currentLibraryDateLastMediaAdded: Map<UUID, String> = emptyMap()
|
||||
|
||||
init {
|
||||
ensureReady()
|
||||
}
|
||||
@@ -140,14 +128,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private suspend fun loadHomeCache() {
|
||||
Timber.tag(TAG).d("Loading home cache")
|
||||
val cache = homeCacheDataStore.data.first()
|
||||
// Hydrate the dateLastMediaAdded map from disk before the first
|
||||
// refresh runs, so the per-library short-circuit has a baseline.
|
||||
cachedLibraryDateLastMediaAdded.clear()
|
||||
cache.libraryDateLastMediaAdded.forEach { (key, date) ->
|
||||
runCatching { UUID.fromString(key) }.getOrNull()?.let { uuid ->
|
||||
cachedLibraryDateLastMediaAdded[uuid] = date
|
||||
}
|
||||
}
|
||||
if (cache.libraries.isNotEmpty()) {
|
||||
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
|
||||
librariesState.value = libraries
|
||||
@@ -190,12 +170,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
val movies = onlineMediaRepository.movies.value
|
||||
val series = onlineMediaRepository.series.value
|
||||
val episodes = onlineMediaRepository.episodes.value
|
||||
// Persist this refresh's dateLastMediaAdded as the new baseline
|
||||
// for the next refresh's per-library short-circuit.
|
||||
cachedLibraryDateLastMediaAdded.clear()
|
||||
currentLibraryDateLastMediaAdded.forEach { (id, date) ->
|
||||
cachedLibraryDateLastMediaAdded[id] = date
|
||||
}
|
||||
val cache = HomeCache(
|
||||
suggestions = suggestionsState.value.map { it.toCachedItem() },
|
||||
continueWatching = continueWatchingState.value.map { it.toCachedItem() },
|
||||
@@ -204,7 +178,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
uuid.toString() to items.map { it.toCachedItem() }
|
||||
}.toMap(),
|
||||
libraries = librariesState.value.map { it.toCachedLibrary() },
|
||||
libraryDateLastMediaAdded = cachedLibraryDateLastMediaAdded.mapKeys { it.key.toString() },
|
||||
movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() },
|
||||
series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() },
|
||||
episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() },
|
||||
@@ -239,93 +212,60 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadLibraries() {
|
||||
private suspend fun loadLibraries() = singleFlight.run("AppContent:loadLibraries") {
|
||||
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load libraries")
|
||||
}
|
||||
val filteredLibraries = librariesItem.filter {
|
||||
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
|
||||
}
|
||||
val emptyLibraries = filteredLibraries.mapNotNull { it.toLibrary(serverUrl()) }
|
||||
librariesState.value = emptyLibraries
|
||||
|
||||
// Build the new library list in a single pass, so collectors of
|
||||
// `librariesState` only ever see the final value. When /UserViews
|
||||
// returns 304 we already have the BaseItemDto list in memory, so
|
||||
// we just rebuild from it. Otherwise we have the new DTOs.
|
||||
val filledLibraries: List<Library> = if (librariesItem == null) {
|
||||
// ETag 304 on /UserViews — re-fetch the count per library
|
||||
// against the existing in-memory state. The size fallback
|
||||
// (`?: library.size`) handles a 304 on /Items too, so a
|
||||
// 304 on both endpoints leaves Library.size untouched.
|
||||
librariesState.value.map { library ->
|
||||
val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size
|
||||
when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count)
|
||||
LibraryKind.SERIES -> library.copy(series = emptyList(), size = count)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Capture the server-reported "last media added" timestamp
|
||||
// per library for this refresh. loadLatestLibraryContent
|
||||
// reads this map to decide which libraries need a
|
||||
// /Items/Latest call, and persistHomeCache writes it to
|
||||
// HomeCache as the new "cached" baseline.
|
||||
currentLibraryDateLastMediaAdded = librariesItem
|
||||
.mapNotNull { dto ->
|
||||
dto.dateLastMediaAdded?.let { dto.id to it.toString() }
|
||||
}
|
||||
.toMap()
|
||||
|
||||
// Count-only refresh: ask the server for the number of items
|
||||
// in each library instead of enumerating them. The home
|
||||
// dashboard only uses Library.size for the library cards;
|
||||
// the full movies/series lists are fetched on demand by the
|
||||
// library detail screen via loadLibraryContent, and on the
|
||||
// home rows by loadSuggestions / loadContinueWatching /
|
||||
// loadLatestLibraryContent.
|
||||
librariesItem.map { dto ->
|
||||
val library = dto.toLibrary(serverUrl())
|
||||
val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size
|
||||
when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count)
|
||||
LibraryKind.SERIES -> library.copy(series = emptyList(), size = count)
|
||||
}
|
||||
}
|
||||
}
|
||||
val filledLibraries = emptyLibraries.map { loadLibrary(it) }
|
||||
librariesState.value = filledLibraries
|
||||
|
||||
val movies = filledLibraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() }
|
||||
onlineMediaRepository.upsertMovies(movies)
|
||||
|
||||
val series = filledLibraries.filter { it.type == LibraryKind.SERIES }.flatMap { it.series.orEmpty() }
|
||||
onlineMediaRepository.upsertSeries(series)
|
||||
}
|
||||
|
||||
override suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>? {
|
||||
val library = librariesState.value.find { it.id == libraryId } ?: return emptyList()
|
||||
// ETag hit — return null so the caller can keep its previously
|
||||
// fetched copy. The library detail viewmodel uses this signal to
|
||||
// preserve its in-memory cache across re-selections.
|
||||
val items = jellyfinApiClient.getLibraryContent(libraryId) ?: return null
|
||||
val url = serverUrl()
|
||||
return when (library.type) {
|
||||
LibraryKind.MOVIES -> items.map { MovieUiModel(it.toMovie(url)) }
|
||||
LibraryKind.SERIES -> items.map { SeriesUiModel(it.toSeries(url)) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadSuggestions() {
|
||||
val url = serverUrl()
|
||||
// Head-first fetch: ask the server for just the first 2 items and
|
||||
// compare to the cached head. If the head is unchanged, the rest
|
||||
// of the row is also unchanged (the home screen shows the head
|
||||
// prominently and only rotates when new content arrives at the
|
||||
// top), so we can skip the full request entirely.
|
||||
val headItems = runCatching { jellyfinApiClient.getSuggestionsHead() }
|
||||
private suspend fun loadLibrary(library: Library): Library {
|
||||
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load suggestions head")
|
||||
handleRefreshFailure(error, "Unable to load library ${library.id}")
|
||||
}
|
||||
return when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(
|
||||
movies = contentItem.map { it.toMovie(serverUrl()) },
|
||||
size = contentItem.size,
|
||||
)
|
||||
LibraryKind.SERIES -> library.copy(
|
||||
series = contentItem.map { it.toSeries(serverUrl()) },
|
||||
size = contentItem.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (headItems == null) return
|
||||
if (headMatches(headItems, suggestionsState.value)) return
|
||||
|
||||
// Head changed (or first refresh) — fetch the full list.
|
||||
private suspend fun loadSuggestions() = singleFlight.run("AppContent:loadSuggestions") {
|
||||
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load suggestions")
|
||||
}
|
||||
if (suggestionsItems == null) return
|
||||
|
||||
// Upsert full episode details BEFORE publishing the new row, so the
|
||||
// home viewmodel's `combine` of this flow with the local media
|
||||
// repository never observes an empty intermediate state. If we
|
||||
// assigned suggestionsState first, the new episode IDs would be
|
||||
// unresolvable for a single emission and the Suggestions section
|
||||
// would briefly drop from the home screen.
|
||||
suggestionsItems.forEach { item ->
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
}
|
||||
}
|
||||
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||
@@ -333,32 +273,24 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert full details so the home viewmodel can look up each item.
|
||||
suggestionsItems.forEach { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
|
||||
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadContinueWatching() {
|
||||
val url = serverUrl()
|
||||
val headItems = runCatching { jellyfinApiClient.getContinueWatchingHead() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load continue watching head")
|
||||
}
|
||||
if (headItems == null) return
|
||||
if (headMatches(headItems, continueWatchingState.value)) return
|
||||
|
||||
private suspend fun loadContinueWatching() = singleFlight.run("AppContent:loadContinueWatching") {
|
||||
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load continue watching")
|
||||
}
|
||||
if (continueWatchingItems == null) return
|
||||
|
||||
// Upsert full episode details BEFORE publishing the new row, so the
|
||||
// home viewmodel's `combine` of this flow with the local media
|
||||
// repository never observes an empty intermediate state. If we
|
||||
// assigned continueWatchingState first, the new episode IDs would
|
||||
// be unresolvable for a single emission and the Continue Watching
|
||||
// section would briefly drop from the home screen.
|
||||
continueWatchingItems.forEach { item ->
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
}
|
||||
}
|
||||
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||
@@ -366,102 +298,57 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${item.type}")
|
||||
}
|
||||
}
|
||||
|
||||
continueWatchingItems.forEach { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
|
||||
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadNextUp() {
|
||||
val url = serverUrl()
|
||||
val headItems = runCatching { jellyfinApiClient.getNextUpHead() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load next up head")
|
||||
}
|
||||
if (headItems == null) return
|
||||
if (headMatches(headItems, nextUpState.value)) return
|
||||
|
||||
private suspend fun loadNextUp() = singleFlight.run("AppContent:loadNextUp") {
|
||||
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load next up")
|
||||
}
|
||||
if (nextUpItems == null) return
|
||||
|
||||
// Upsert full episode details BEFORE publishing the new row, so the
|
||||
// home viewmodel's `combine` of this flow with the local media
|
||||
// repository never observes an empty intermediate state. If we
|
||||
// assigned nextUpState first, the new episode IDs would be
|
||||
// unresolvable for a single emission and the Next Up section
|
||||
// would briefly drop from the home screen.
|
||||
nextUpItems.forEach { item ->
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
}
|
||||
nextUpState.value = nextUpItems.map { item ->
|
||||
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
||||
}
|
||||
|
||||
nextUpItems.forEach { item ->
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadLatestLibraryContent() {
|
||||
private suspend fun loadLatestLibraryContent() = singleFlight.run("AppContent:loadLatestLibraryContent") {
|
||||
// Reuse the libraries already loaded by loadLibraries() so we don't refetch
|
||||
// the same /Users/{userId}/Views response a second time per refresh.
|
||||
val filteredLibraries = librariesState.value
|
||||
val url = serverUrl()
|
||||
val latestLibraryContents = filteredLibraries.associate { library ->
|
||||
// Layer 1: skip when the server-reported "last media added"
|
||||
// timestamp is unchanged since the last refresh. This is the
|
||||
// cheapest check (no request) and handles the common "no new
|
||||
// content" case.
|
||||
val cachedDate = cachedLibraryDateLastMediaAdded[library.id]
|
||||
val currentDate = currentLibraryDateLastMediaAdded[library.id]
|
||||
if (cachedDate != null && cachedDate == currentDate) {
|
||||
library.id to (latestLibraryContentState.value[library.id] ?: emptyList())
|
||||
} else {
|
||||
// Layer 2: head-only fetch. If the first 2 items of the
|
||||
// latest row are the same as the cached head, the rest of
|
||||
// the row is also unchanged (the home row only rotates at
|
||||
// the top) and we can skip the full request.
|
||||
val headDtos = runCatching { jellyfinApiClient.getLatestFromLibraryHead(library.id) }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load latest head for library ${library.id}")
|
||||
}
|
||||
val cachedSlice = latestLibraryContentState.value[library.id].orEmpty()
|
||||
if (headDtos == null || headMatches(headDtos, cachedSlice)) {
|
||||
library.id to cachedSlice
|
||||
} else {
|
||||
// Layer 3: head changed — fetch the full row.
|
||||
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
|
||||
}
|
||||
if (latestFromLibrary == null) {
|
||||
library.id to cachedSlice
|
||||
} else {
|
||||
val media = when (library.type) {
|
||||
library.id to when (library.type) {
|
||||
LibraryKind.MOVIES -> latestFromLibrary.map {
|
||||
val movie = it.toMovie(url)
|
||||
onlineMediaRepository.upsertMovies(listOf(movie))
|
||||
Media.MovieMedia(movieId = movie.id)
|
||||
}
|
||||
LibraryKind.SERIES -> latestFromLibrary.map { dto ->
|
||||
when (dto.type) {
|
||||
LibraryKind.SERIES -> latestFromLibrary.map {
|
||||
when (it.type) {
|
||||
BaseItemKind.SERIES -> {
|
||||
val series = dto.toSeries(url)
|
||||
onlineMediaRepository.upsertSeries(listOf(series))
|
||||
val series = it.toSeries(url)
|
||||
Media.SeriesMedia(seriesId = series.id)
|
||||
}
|
||||
BaseItemKind.SEASON -> {
|
||||
val season = dto.toSeason()
|
||||
val season = it.toSeason()
|
||||
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
|
||||
}
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = dto.toEpisode(url)
|
||||
onlineMediaRepository.upsertEpisodes(listOf(episode))
|
||||
val episode = it.toEpisode(url)
|
||||
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${dto.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
library.id to media
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,29 +356,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
latestLibraryContentState.value = latestLibraryContents
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the first [JellyfinApiClient.HEAD_LIMIT] IDs of the
|
||||
* freshly fetched [headDtos] match the first
|
||||
* [JellyfinApiClient.HEAD_LIMIT] IDs of the cached [cachedMedia]. Used
|
||||
* by the home row refreshers to short-circuit the full request when
|
||||
* the head of the row is unchanged.
|
||||
*
|
||||
* Order-sensitive: the comparison is positional. The Jellyfin server
|
||||
* can reorder rows (e.g., Suggestions reorders on a relevance
|
||||
* recompute even when the item set is unchanged), which will report
|
||||
* a false "head changed" and pay for the full request. The reverse
|
||||
* false negative — head order unchanged but a mid-list item rotated —
|
||||
* is a known limitation of the head-diff heuristic.
|
||||
*/
|
||||
private fun headMatches(
|
||||
headDtos: List<BaseItemDto>,
|
||||
cachedMedia: List<Media>,
|
||||
): Boolean {
|
||||
val headIds = headDtos.take(JellyfinApiClient.HEAD_LIMIT).map { it.id }
|
||||
val cachedIds = cachedMedia.take(JellyfinApiClient.HEAD_LIMIT).map { it.id }
|
||||
return headIds == cachedIds
|
||||
}
|
||||
|
||||
private suspend fun serverUrl(): String {
|
||||
return userSessionRepository.serverUrl.first()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.toEpisode
|
||||
@@ -11,9 +12,6 @@ import hu.bbara.purefin.model.Episode
|
||||
import hu.bbara.purefin.model.Movie
|
||||
import hu.bbara.purefin.model.Series
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -32,10 +30,9 @@ import javax.inject.Singleton
|
||||
class InMemoryLocalMediaRepository @Inject constructor(
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val singleFlight: SingleFlight,
|
||||
) : LocalMediaRepository {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val serverUrl = userSessionRepository.serverUrl
|
||||
|
||||
private val moviesState = MutableStateFlow<Map<UUID, Movie>>(emptyMap())
|
||||
@@ -56,13 +53,12 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
override suspend fun getEpisode(id: UUID): Flow<Episode?> =
|
||||
episodesState.map { it[id] }.distinctUntilChanged()
|
||||
|
||||
override suspend fun loadMovie(id: UUID) {
|
||||
if (moviesState.value.containsKey(id)) return
|
||||
override suspend fun loadMovie(id: UUID) = singleFlight.run("LocalMedia:loadMovie:$id") {
|
||||
try {
|
||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||
if (item.type != BaseItemKind.MOVIE) {
|
||||
Timber.tag(TAG).d("Item is not an movie: ${item.type}")
|
||||
return
|
||||
return@run
|
||||
}
|
||||
val movie = item.toMovie(serverUrl.first())
|
||||
moviesState.update { current -> current + (movie.id to movie) }
|
||||
@@ -75,16 +71,23 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadSeries(id: UUID) {
|
||||
if (seriesState.value.containsKey(id)) return
|
||||
override suspend fun loadSeries(id: UUID) = singleFlight.run("LocalMedia:loadSeries:$id") {
|
||||
try {
|
||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||
if (item.type != BaseItemKind.SERIES) {
|
||||
Timber.tag(TAG).d("Item is not an series: ${item.type}")
|
||||
return
|
||||
return@run
|
||||
}
|
||||
val series = item.toSeries(serverUrl.first())
|
||||
seriesState.update { current -> current + (series.id to series) }
|
||||
seriesState.update { current ->
|
||||
val existing = current[series.id]
|
||||
val merged = if (existing != null && existing.seasons.isNotEmpty() && series.seasons.isEmpty()) {
|
||||
series.copy(seasons = existing.seasons)
|
||||
} else {
|
||||
series
|
||||
}
|
||||
current + (series.id to merged)
|
||||
}
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
@@ -94,13 +97,12 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadEpisode(id: UUID) {
|
||||
if (episodesState.value.containsKey(id)) return
|
||||
override suspend fun loadEpisode(id: UUID) = singleFlight.run("LocalMedia:loadEpisode:$id") {
|
||||
try {
|
||||
jellyfinApiClient.getItemInfo(id)?.let { item ->
|
||||
if (item.type != BaseItemKind.EPISODE) {
|
||||
Timber.tag(TAG).d("Item is not an episode: ${item.type}")
|
||||
return
|
||||
return@run
|
||||
}
|
||||
val episode = item.toEpisode(serverUrl.first())
|
||||
episodesState.update { current -> current + (episode.id to episode) }
|
||||
@@ -118,67 +120,67 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
}
|
||||
|
||||
fun upsertSeries(series: List<Series>) {
|
||||
seriesState.update { current -> current + series.associateBy { it.id } }
|
||||
seriesState.update { current ->
|
||||
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)
|
||||
} else {
|
||||
newSeries
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertEpisodes(episodes: List<Episode>) {
|
||||
episodesState.update { current -> current + episodes.associateBy { it.id } }
|
||||
}
|
||||
|
||||
override suspend fun loadSeasons(seriesId: UUID) {
|
||||
override suspend fun loadSeasons(seriesId: UUID) = singleFlight.run("LocalMedia:loadSeasons:$seriesId") {
|
||||
try {
|
||||
loadSeasonsInternal(seriesId)
|
||||
// Ensure the series is loaded
|
||||
var series = seriesState.value[seriesId]
|
||||
if (series == null) {
|
||||
loadSeries(seriesId)
|
||||
series = seriesState.first()[seriesId] ?: throw RuntimeException("Series not found")
|
||||
}
|
||||
|
||||
val updatedSeries = series.copy(
|
||||
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
|
||||
)
|
||||
seriesState.update { it + (updatedSeries.id to updatedSeries) }
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
Timber.tag(TAG).e(error, "Failed to load content for series $seriesId")
|
||||
Timber.tag(TAG).e(error, "Failed to load seasons for series $seriesId")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadSeasonsInternal(seriesId: UUID) {
|
||||
seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
|
||||
|
||||
val seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
|
||||
|
||||
// Await the parallel loadSeries to populate the series entry.
|
||||
// Precondition: seriesId refers to a real series (selectSeries always uses a
|
||||
// SeriesDto from a real series list). If loadSeries returns without populating
|
||||
// state (null item / wrong type) this suspends until cancelled by structured
|
||||
// concurrency when a sibling load* call fails.
|
||||
seriesState.first { it.containsKey(seriesId) }
|
||||
|
||||
seriesState.update { current ->
|
||||
val existing = current[seriesId] ?: return@update current
|
||||
if (existing.seasons.isNotEmpty()) return@update current
|
||||
current + (seriesId to existing.copy(seasons = seasons))
|
||||
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) =
|
||||
singleFlight.run("LocalMedia:loadSeasonEpisodes:$seriesId:$seasonId") {
|
||||
try {
|
||||
// Ensure the series and season is loaded
|
||||
var series = seriesState.value[seriesId]
|
||||
if (series == null) {
|
||||
loadSeries(seriesId)
|
||||
series = seriesState.first()[seriesId] ?: throw RuntimeException("Series not found")
|
||||
}
|
||||
// The season we are about to load episodes for must be present
|
||||
// in `series.seasons` for the `seriesState.update { ... }` block
|
||||
// below to attach the fetched episodes to it; otherwise the
|
||||
// `map { ... }` iterates over an empty list and the freshly
|
||||
// fetched episodes are silently dropped, leaving the series
|
||||
// detail screen stuck on "Loading seasons…". If the seasons
|
||||
// have not been hydrated yet (e.g. `loadSeasons` from
|
||||
// `selectSeries` is still racing with us), load them first.
|
||||
if (series.seasons.none { it.id == seasonId }) {
|
||||
loadSeasons(seriesId)
|
||||
}
|
||||
|
||||
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
|
||||
// Fast path: season already cached with episodes or known empty — skip the fetch.
|
||||
seriesState.value[seriesId]?.seasons?.firstOrNull { it.id == seasonId }?.let { cached ->
|
||||
if (cached.episodes.isNotEmpty() || cached.episodeCount == 0) return
|
||||
}
|
||||
|
||||
// Fetch first so this runs concurrently with loadSeries/loadSeasons when launched
|
||||
// from selectSeries. Precondition: seasons are pre-loaded by selectSeries and
|
||||
// seasonId is always a valid season from a real EpisodeDto in this path.
|
||||
val serverUrl = userSessionRepository.serverUrl.first()
|
||||
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
|
||||
.map { it.toEpisode(serverUrl) }
|
||||
|
||||
// Await the season to be present in state (immediate when pre-loaded; waits for
|
||||
// the parallel loadSeasons otherwise).
|
||||
seriesState.first { it[seriesId]?.seasons?.any { it.id == seasonId } == true }
|
||||
|
||||
// Re-check guard after await: a concurrent call may have filled episodes already.
|
||||
val series = seriesState.value[seriesId] ?: return
|
||||
val season = series.seasons.firstOrNull { it.id == seasonId } ?: return
|
||||
if (season.episodes.isNotEmpty() || season.episodeCount == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
seriesState.update { current ->
|
||||
val currentSeries = current[seriesId] ?: return@update current
|
||||
val updatedSeries = currentSeries.copy(
|
||||
@@ -193,6 +195,12 @@ class InMemoryLocalMediaRepository @Inject constructor(
|
||||
current + (updatedSeries.id to updatedSeries)
|
||||
}
|
||||
episodesState.update { current -> current + episodes.associateBy { it.id } }
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
Timber.tag(TAG).e(error, "Failed to load episodes for season $seasonId")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
|
||||
|
||||
@@ -11,12 +11,13 @@ import hu.bbara.purefin.model.Season
|
||||
import hu.bbara.purefin.model.Series
|
||||
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.concurrent.TimeUnit
|
||||
|
||||
fun BaseItemDto.toLibrary(serverUrl: String): Library {
|
||||
fun BaseItemDto.toLibrary(serverUrl: String): Library? {
|
||||
return when (collectionType) {
|
||||
CollectionType.MOVIES -> Library(
|
||||
id = id,
|
||||
@@ -42,9 +43,19 @@ fun BaseItemDto.toLibrary(serverUrl: String): Library {
|
||||
size = childCount ?: 0,
|
||||
series = emptyList(),
|
||||
)
|
||||
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType")
|
||||
else -> {
|
||||
// Jellyfin servers can return other top-level views (e.g. boxsets,
|
||||
// music, photos, live TV) that this app does not surface on the
|
||||
// home dashboard. Skip them so a single unsupported view does
|
||||
// not abort the whole refresh and leave the user with an empty
|
||||
// home screen.
|
||||
Timber.tag(TAG).w("Skipping unsupported library type: $collectionType")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BaseItemDtoConverter"
|
||||
|
||||
fun BaseItemDto.toMovie(serverUrl: String): Movie {
|
||||
return Movie(
|
||||
|
||||
@@ -8,8 +8,6 @@ import hu.bbara.purefin.core.data.PlaybackMethod
|
||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||
import hu.bbara.purefin.core.data.QuickConnectSession
|
||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient
|
||||
import hu.bbara.purefin.data.jellyfin.etag.NotModifiedException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -34,7 +32,6 @@ import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||
@@ -69,12 +66,10 @@ import javax.inject.Singleton
|
||||
class JellyfinApiClient @Inject constructor(
|
||||
@ApplicationContext private val applicationContext: Context,
|
||||
private val userSessionRepository: UserSessionRepository,
|
||||
@JellyfinSdkClient private val okHttpFactory: OkHttpFactory,
|
||||
) {
|
||||
private val jellyfin = createJellyfin {
|
||||
context = applicationContext
|
||||
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
|
||||
apiClientFactory = okHttpFactory
|
||||
}
|
||||
|
||||
val api = jellyfin.createApi()
|
||||
@@ -219,21 +214,17 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLibraries(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
logRequest("getLibraries") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
return@logRequest emptyList()
|
||||
}
|
||||
try {
|
||||
val response = api.userViewsApi.getUserViews(
|
||||
userId = getUserId(),
|
||||
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
||||
includeHidden = false,
|
||||
)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,12 +238,11 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
logRequest("getLibraryContent") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
return@logRequest emptyList()
|
||||
}
|
||||
try {
|
||||
val getItemsRequest = GetItemsRequest(
|
||||
userId = getUserId(),
|
||||
enableImages = true,
|
||||
@@ -264,52 +254,15 @@ class JellyfinApiClient @Inject constructor(
|
||||
)
|
||||
val response = api.itemsApi.getItems(getItemsRequest)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of items in [libraryId] without enumerating
|
||||
* them. Uses `getItems` with `limit=0, enableTotalRecordCount=true`,
|
||||
* which the Jellyfin server short-circuits to a single integer in the
|
||||
* response. The home page uses this to populate `Library.size` for the
|
||||
* library cards on the dashboard without paying for the full library
|
||||
* content.
|
||||
*
|
||||
* ETag is honored via the [ETagInterceptor]: `null` means the cached
|
||||
* count is still current.
|
||||
*/
|
||||
suspend fun getLibraryItemCount(libraryId: UUID): Int? = withContext(Dispatchers.IO) {
|
||||
logRequest("getLibraryItemCount") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest 0
|
||||
}
|
||||
try {
|
||||
val request = GetItemsRequest(
|
||||
userId = getUserId(),
|
||||
parentId = libraryId,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
recursive = true,
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
)
|
||||
val response = api.itemsApi.getItems(request)
|
||||
response.content.totalRecordCount
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSuggestions(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
suspend fun getSuggestions(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
logRequest("getSuggestions") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
return@logRequest emptyList()
|
||||
}
|
||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
||||
try {
|
||||
val userId = getUserId() ?: return@logRequest emptyList()
|
||||
val response = api.suggestionsApi.getSuggestions(
|
||||
userId = userId,
|
||||
mediaType = listOf(MediaType.VIDEO),
|
||||
@@ -318,19 +271,15 @@ class JellyfinApiClient @Inject constructor(
|
||||
enableTotalRecordCount = true,
|
||||
)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getContinueWatching(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
logRequest("getContinueWatching") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
return@logRequest emptyList()
|
||||
}
|
||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
||||
try {
|
||||
val userId = getUserId() ?: return@logRequest emptyList()
|
||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
@@ -340,18 +289,14 @@ class JellyfinApiClient @Inject constructor(
|
||||
)
|
||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getNextUpEpisodes(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
logRequest("getNextUpEpisodes") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
throw IllegalStateException("Not configured")
|
||||
}
|
||||
try {
|
||||
val getNextUpRequest = GetNextUpRequest(
|
||||
userId = getUserId(),
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
@@ -359,18 +304,14 @@ class JellyfinApiClient @Inject constructor(
|
||||
)
|
||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
||||
result.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
logRequest("getLatestFromLibrary") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
return@logRequest emptyList()
|
||||
}
|
||||
try {
|
||||
val response = api.userLibraryApi.getLatestMedia(
|
||||
userId = getUserId(),
|
||||
parentId = libraryId,
|
||||
@@ -379,108 +320,6 @@ class JellyfinApiClient @Inject constructor(
|
||||
limit = 15,
|
||||
)
|
||||
response.content
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Head-only fetch for the suggestions row. Returns the first [limit]
|
||||
* items so the caller can diff against the cached head and decide
|
||||
* whether the full request is necessary. ETag is honored via the
|
||||
* [ETagInterceptor]; `null` means the cached head is still current.
|
||||
*/
|
||||
suspend fun getSuggestionsHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getSuggestionsHead") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
try {
|
||||
val response = api.suggestionsApi.getSuggestions(
|
||||
userId = getUserId(),
|
||||
mediaType = listOf(MediaType.VIDEO),
|
||||
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||
limit = limit,
|
||||
enableTotalRecordCount = false,
|
||||
)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Head-only fetch for the continue-watching row. See [getSuggestionsHead].
|
||||
*/
|
||||
suspend fun getContinueWatchingHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getContinueWatchingHead") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
||||
try {
|
||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = defaultItemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||
enableUserData = true,
|
||||
startIndex = 0,
|
||||
limit = limit,
|
||||
)
|
||||
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||
response.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Head-only fetch for the next-up row. See [getSuggestionsHead].
|
||||
*/
|
||||
suspend fun getNextUpHead(limit: Int = HEAD_LIMIT): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getNextUpHead") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
try {
|
||||
val getNextUpRequest = GetNextUpRequest(
|
||||
userId = getUserId(),
|
||||
fields = defaultItemFields,
|
||||
enableResumable = false,
|
||||
limit = limit,
|
||||
)
|
||||
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
|
||||
result.content.items
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Head-only fetch for the latest row of a library. See [getSuggestionsHead].
|
||||
*/
|
||||
suspend fun getLatestFromLibraryHead(libraryId: UUID, limit: Int = HEAD_LIMIT): List<BaseItemDto>? =
|
||||
withContext(Dispatchers.IO) {
|
||||
logRequest("getLatestFromLibraryHead") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
try {
|
||||
val response = api.userLibraryApi.getLatestMedia(
|
||||
userId = getUserId(),
|
||||
parentId = libraryId,
|
||||
fields = defaultItemFields,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
|
||||
limit = limit,
|
||||
)
|
||||
response.content
|
||||
} catch (e: NotModifiedException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,9 +635,5 @@ class JellyfinApiClient @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "JellyfinApiClient"
|
||||
// How many items to fetch on the head-only path. Two is enough to
|
||||
// detect the common case of "first card unchanged" without making
|
||||
// the head request meaningfully heavier than the minimum.
|
||||
const val HEAD_LIMIT = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package hu.bbara.purefin.data.jellyfin.etag
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* In-memory store of `ETag` response headers, keyed by full request URL. The
|
||||
* [ETagInterceptor] reads from this store to add `If-None-Match` to outgoing
|
||||
* requests and writes to it on every successful response.
|
||||
*
|
||||
* ETag handling is scoped to a small set of home-refresh URL patterns
|
||||
* matched against path segments. All other endpoints (per-item lookups,
|
||||
* search, sessions, system info, etc.) are passed through unchanged. This
|
||||
* keeps the 304 → [NotModifiedException] translation from leaking into code
|
||||
* paths that do not expect it, and prevents search and similar list-shaped
|
||||
* calls from being treated as cacheable.
|
||||
*
|
||||
* The cache is process-scoped: ETags are lost on process death, which means
|
||||
* the first request after each app start always goes out without
|
||||
* `If-None-Match`. This is acceptable — the server returns the same data
|
||||
* with a fresh ETag, and subsequent requests benefit from the cache.
|
||||
*/
|
||||
@Singleton
|
||||
class ETagCache @Inject constructor() {
|
||||
private val etags = ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* Returns `true` if requests to [url] should use ETag-based conditional
|
||||
* fetching. Only home-refresh endpoints are eligible, and the
|
||||
* `/Items` list endpoint is only eligible when it is being used as a
|
||||
* per-library content query (a `parentId` query parameter is present
|
||||
* and no search-shaped parameters are set).
|
||||
*/
|
||||
fun isEligible(url: HttpUrl): Boolean = matchesHomeRefresh(url)
|
||||
|
||||
/**
|
||||
* Returns the cached ETag for [url], or `null` if none has been stored
|
||||
* yet.
|
||||
*/
|
||||
fun get(url: String): String? = etags[url]
|
||||
|
||||
/**
|
||||
* Cache [etag] for [url]. The interceptor writes here after every
|
||||
* eligible 2xx response that carried an ETag header.
|
||||
*/
|
||||
fun put(url: String, etag: String) {
|
||||
etags[url] = etag
|
||||
}
|
||||
|
||||
private fun matchesHomeRefresh(url: HttpUrl): Boolean {
|
||||
val segments = url.pathSegments
|
||||
return when {
|
||||
// /Users/{userId}/Views — get libraries
|
||||
segments.size == 3 && segments[0] == "Users" && segments[2] == "Views" -> true
|
||||
// /Items/Suggestions — get suggestions
|
||||
segments == listOf("Items", "Suggestions") -> true
|
||||
// /Users/{userId}/Items/Resume — get resume items (continue watching)
|
||||
segments.size == 4 &&
|
||||
segments[0] == "Users" &&
|
||||
segments[2] == "Items" &&
|
||||
segments[3] == "Resume" -> true
|
||||
// /Shows/NextUp — get next up episodes
|
||||
segments == listOf("Shows", "NextUp") -> true
|
||||
// /Users/{userId}/Items/Latest — latest items in a library
|
||||
segments.size == 4 &&
|
||||
segments[0] == "Users" &&
|
||||
segments[2] == "Items" &&
|
||||
segments[3] == "Latest" -> true
|
||||
// /Items — per-library content list, distinguished from search
|
||||
// by the presence of `parentId` and the absence of search params.
|
||||
segments == listOf("Items") -> isPerLibraryItemsQuery(url)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPerLibraryItemsQuery(url: HttpUrl): Boolean {
|
||||
// The per-library content call always sets parentId; the search
|
||||
// calls set searchTerm or genres but never parentId. Matching
|
||||
// on parentId alone is enough to keep the ETag scope tight.
|
||||
return url.queryParameter("parentId") != null
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package hu.bbara.purefin.data.jellyfin.etag
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* OkHttp interceptor that turns Jellyfin's ETag support into a
|
||||
* "not modified" signal the SDK caller can act on.
|
||||
*
|
||||
* For URLs whose path is in [ETagCache.isEligible]:
|
||||
* - If a cached ETag exists, the request is sent with `If-None-Match`.
|
||||
* - If the server replies 304, the response is closed and a
|
||||
* [NotModifiedException] is thrown so the caller can keep using its
|
||||
* existing copy of the data.
|
||||
* - If the server replies 2xx with an ETag header, the new ETag is cached.
|
||||
*
|
||||
* All other URLs are passed through untouched. This bounds the surface
|
||||
* area of the 304 handling: only code paths that explicitly know about
|
||||
* the home-refresh contract need to catch [NotModifiedException].
|
||||
*/
|
||||
@Singleton
|
||||
class ETagInterceptor @Inject constructor(
|
||||
private val cache: ETagCache,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
if (!cache.isEligible(request.url)) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val url = request.url.toString()
|
||||
val etag = cache.get(url)
|
||||
val outgoing = if (etag != null) {
|
||||
request.newBuilder().header(HEADER_IF_NONE_MATCH, etag).build()
|
||||
} else {
|
||||
request
|
||||
}
|
||||
|
||||
val response = chain.proceed(outgoing)
|
||||
|
||||
if (response.code == HTTP_NOT_MODIFIED) {
|
||||
// Close before throwing so OkHttp does not warn about leaked
|
||||
// responses. The caller will treat this as "use the cached copy".
|
||||
response.close()
|
||||
Timber.tag(TAG).d("ETag hit for %s", url)
|
||||
throw NotModifiedException()
|
||||
}
|
||||
|
||||
// Cache the ETag for next time. Only successful responses carry
|
||||
// meaningful ETags; error responses may carry caching hints but
|
||||
// we should not treat a 5xx body as a valid cache key.
|
||||
if (response.isSuccessful) {
|
||||
response.header(HEADER_ETAG)?.let { newEtag ->
|
||||
cache.put(url, newEtag)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ETagInterceptor"
|
||||
const val HEADER_ETAG = "ETag"
|
||||
const val HEADER_IF_NONE_MATCH = "If-None-Match"
|
||||
const val HTTP_NOT_MODIFIED = 304
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package hu.bbara.purefin.data.jellyfin.etag
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Provides the [OkHttpClient] (with [ETagInterceptor] installed) and the
|
||||
* [OkHttpFactory] used by the Jellyfin SDK. The client is qualified with
|
||||
* [JellyfinSdkClient] so it is distinct from the unqualified OkHttpClient
|
||||
* in `core.jellyfin.JellyfinNetworkModule`, which is used for image and
|
||||
* media streaming. The Jellyfin SDK provides its own auth header on every
|
||||
* request, so the SDK-bound client does not need `JellyfinAuthInterceptor`.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object JellyfinOkHttpModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@JellyfinSdkClient
|
||||
fun provideJellyfinSdkOkHttpClient(etagInterceptor: ETagInterceptor): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(etagInterceptor)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@JellyfinSdkClient
|
||||
fun provideJellyfinOkHttpFactory(@JellyfinSdkClient client: OkHttpClient): OkHttpFactory =
|
||||
OkHttpFactory(base = client)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package hu.bbara.purefin.data.jellyfin.etag
|
||||
|
||||
/**
|
||||
* Thrown by [ETagInterceptor] when the Jellyfin server returns 304 Not Modified
|
||||
* for a request that had an `If-None-Match` header. Callers should treat this
|
||||
* as "the cached copy is still valid" and avoid re-processing the response.
|
||||
*
|
||||
* This is a control-flow signal, not an error — it propagates out of
|
||||
* `OkHttpClient.await()` before the SDK can deserialize a 304 body. Only
|
||||
* URLs that have been registered in [ETagCache] can produce this exception.
|
||||
*/
|
||||
class NotModifiedException : RuntimeException("ETag match — server returned 304 Not Modified")
|
||||
@@ -91,7 +91,6 @@ data class HomeCache(
|
||||
val nextUp: List<CachedMediaItem> = emptyList(),
|
||||
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap(),
|
||||
val libraries: List<CachedLibrary> = emptyList(),
|
||||
val libraryDateLastMediaAdded: Map<String, String> = emptyMap(),
|
||||
val movies: List<CachedMovie> = emptyList(),
|
||||
val series: List<CachedSeries> = emptyList(),
|
||||
val episodes: List<CachedEpisode> = emptyList()
|
||||
|
||||
@@ -28,6 +28,6 @@ android.dependency.useConstraints=true
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
purefinReleaseVersionCode=1000030
|
||||
purefinReleaseVersionCode=1000032
|
||||
purefinDebugVersionCode=1000003
|
||||
purefinTvVersionCode=2000014
|
||||
purefinTvVersionCode=2000016
|
||||
|
||||
Reference in New Issue
Block a user