Compare commits

...

19 Commits

Author SHA1 Message Date
c2cc4e9e65 revert(data): remove ETag caching and head-fetch home refresh
Reverts 5491c38 (ETag-based conditional HTTP caching for home refresh) and 1ec9ff9 (head-fetch rows and count-only libraries). Restores the pre-ETag home refresh path that fully loads library content per library and fetches each home row in a single request.

Preserves unrelated later improvements: SingleFlight deduplication (86f44ac), nullable toLibrary skip for unsupported library types (80b1bd8), and upsert-before-publish ordering to prevent empty intermediate states (f0a7828).
2026-06-26 15:44:05 +00:00
13bc9410b4 chore(build): increment app-tv versionCode to 2000016 2026-06-25 21:17:10 +02:00
422642ad19 chore(build): increment app versionCode to 1000032 2026-06-25 21:14:33 +02:00
59315b701d fix(repository): merge existing seasons when updating series with empty seasons 2026-06-25 21:09:43 +02:00
3c46650a63 fix(repository): enhance series upsert logic to preserve existing seasons 2026-06-25 20:47:38 +02:00
f0a7828613 refactor(repository): reorder state updates to prevent empty intermediate states 2026-06-22 20:30:31 +02:00
8c758e8262 feat(tv-player): add live clock overlay to player controls 2026-06-22 18:27:09 +00:00
9872f401f8 refactor(data): remove redundant dedup guards and improve loading robustness 2026-06-22 18:09:50 +00:00
86f44ac9ce refactor(data): deduplicate concurrent data fetches with SingleFlight 2026-06-22 17:26:50 +00:00
d80b283ded fix(repository): streamline season loading and update series state 2026-06-22 19:20:20 +02:00
1ac649e8ae fix(tv-player): remove unused seek live edge functionality and related UI elements 2026-06-22 18:41:23 +02:00
b23db4bab3 fix(player): keep seek bar thumb at target on release
Previously, the Slider's value was switched from the local sliderPosition to
the positionMs prop the instant onValueChangeFinished cleared isScrubbing.
Because the prop had not yet caught up with the committed seek, the thumb
visibly snapped back to the pre-seek position before jumping to the target.

Drive the slider from sliderPosition unconditionally, sync sliderPosition
from the prop via a LaunchedEffect only when not scrubbing, and pin
sliderPosition to the committed target before clearing isScrubbing in
onValueChangeFinished.
2026-06-21 11:17:16 +00:00
67a141e7d7 chore(build): increment app versionCode to 1000031 2026-06-21 10:25:20 +02:00
9f432edd27 chore(build): increment app-tv versionCode to 2000015 2026-06-20 21:50:31 +02:00
80b1bd8547 fix(data): gracefully skip unsupported library types during refresh
Previously, toLibrary() threw UnsupportedOperationException for
library types the app does not surface (e.g. boxsets, music, photos,
live TV), which aborted the entire home screen refresh. Now these
unsupported types are logged and skipped, preventing a single
unexpected view from leaving the user with an empty home screen.
2026-06-20 21:50:15 +02:00
00c5689195 fix(tv-player): Do not show hidden seek line when skip button is shown 2026-06-20 21:12:13 +02:00
bd2c1777a5 fix(tv-player): Only show paused feedback 2026-06-20 21:12:12 +02:00
a5d04998ac refactor(tv): extract hero backdrop and simplify gradient drawing
Split the hero backdrop (image + gradient overlays) into TvHomeHeroBackdrop, rendered behind the home content. Replace nested Box/background gradient approach with drawWithContent for better composability. Clean up unused imports across TvHomeScreen, TvFocusedItemHero, and TvHomeContent.
2026-06-20 18:52:09 +00:00
da89a8a661 fix(tv): adjust hero height fraction for better layout on TV screens 2026-06-20 20:22:49 +02:00
21 changed files with 542 additions and 928 deletions

View File

@@ -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,30 +31,33 @@ fun TvHomeScreen(
val focusedMediaUiModel = remember { mutableStateOf<MediaUiModel>(MovieUiModel.createPlaceholder()) }
Surface(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
modifier = modifier.fillMaxSize(),
color = scheme.background
) {
Column(
modifier = Modifier.fillMaxSize()
) {
TvFocusedItemHero(
item = focusedMediaUiModel.value
)
TvHomeContent(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMediaFocused = {
focusedMediaUiModel.value = it
},
onMediaSelected = onMediaSelected,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
Box(modifier = Modifier.fillMaxSize()) {
TvHomeHeroBackdrop(
backdropImageUrl = focusedMediaUiModel.value.backdropImageUrl
)
Column(
modifier = Modifier.fillMaxSize()
) {
TvFocusedItemHero(
item = focusedMediaUiModel.value
)
TvHomeContent(
libraries = libraries,
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMediaFocused = {
focusedMediaUiModel.value = it
},
onMediaSelected = onMediaSelected,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
}
}
}
}

View File

@@ -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
)
}
// Darken the left side so the text block stays readable over any backdrop.
Box(
modifier = Modifier
.fillMaxSize()
.background(
.drawWithContent {
drawContent()
// Darken the left side so the text block stays readable over any backdrop.
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(
// Strong bottom fade so the title and metadata are legible.
drawRect(
Brush.verticalGradient(
colorStops = arrayOf(
0.0f to scheme.background.copy(alpha = 0f),
@@ -89,43 +78,62 @@ internal fun TvFocusedItemHero(
)
)
)
}
) { imageUrl ->
PurefinAsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Crossfade(
targetState = item,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-content"
) { hero ->
}
}
@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",
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(heightFraction)
) { hero ->
Column(
verticalArrangement = Arrangement.Bottom,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 16.dp)
) {
Column(
verticalArrangement = Arrangement.Bottom,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 40.dp, vertical = 16.dp)
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.widthIn(max = 720.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.widthIn(max = 720.dp)
) {
Text(
text = hero.primaryText,
color = scheme.onBackground,
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
lineHeight = 44.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(TvHomeHeroTitleTag)
)
TvHomeHeroMetadataRow(item = hero)
if (hero.description.isNotBlank()) {
Text(
text = hero.primaryText,
color = scheme.onBackground,
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
lineHeight = 44.sp,
text = hero.description,
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(TvHomeHeroTitleTag)
overflow = TextOverflow.Ellipsis
)
TvHomeHeroMetadataRow(item = hero)
if (hero.description.isNotBlank()) {
Text(
text = hero.description,
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -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

View File

@@ -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,20 +379,22 @@ fun TvPlayerScreen(
}
}
ValueChangeTimedVisibility(
value = hiddenSeekCounter,
hideAfterMillis = 2500L,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 32.dp, vertical = 28.dp)
) {
HiddenTvSeekTimeline(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads
)
if (!showSkipIntroButton) {
ValueChangeTimedVisibility(
value = hiddenSeekCounter,
hideAfterMillis = 2500L,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 32.dp, vertical = 28.dp)
) {
HiddenTvSeekTimeline(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
bufferedMs = uiState.bufferedMs,
chapterMarkers = uiState.chapters,
adMarkers = uiState.ads
)
}
}
TvPlayerLoadingErrorEndCard(
@@ -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)

View File

@@ -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",

View File

@@ -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)
},

View File

@@ -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
}
}

View File

@@ -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>?
}

View File

@@ -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())

View File

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

View File

@@ -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

View File

@@ -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")
}
// 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 filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
}
val emptyLibraries = filteredLibraries.mapNotNull { it.toLibrary(serverUrl()) }
librariesState.value = emptyLibraries
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()
private suspend fun loadLibrary(library: Library): Library {
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load library ${library.id}")
}
return when (library.type) {
LibraryKind.MOVIES -> items.map { MovieUiModel(it.toMovie(url)) }
LibraryKind.SERIES -> items.map { SeriesUiModel(it.toSeries(url)) }
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,
)
}
}
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() }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load suggestions head")
}
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}")
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
}
library.id to when (library.type) {
LibraryKind.MOVIES -> latestFromLibrary.map {
val movie = it.toMovie(url)
Media.MovieMedia(movieId = movie.id)
}
LibraryKind.SERIES -> latestFromLibrary.map {
when (it.type) {
BaseItemKind.SERIES -> {
val series = it.toSeries(url)
Media.SeriesMedia(seriesId = series.id)
}
if (latestFromLibrary == null) {
library.id to cachedSlice
} else {
val media = 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) {
BaseItemKind.SERIES -> {
val series = dto.toSeries(url)
onlineMediaRepository.upsertSeries(listOf(series))
Media.SeriesMedia(seriesId = series.id)
}
BaseItemKind.SEASON -> {
val season = dto.toSeason()
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
}
BaseItemKind.EPISODE -> {
val episode = dto.toEpisode(url)
onlineMediaRepository.upsertEpisodes(listOf(episode))
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
}
else -> throw UnsupportedOperationException("Unsupported item type: ${dto.type}")
}
}
BaseItemKind.SEASON -> {
val season = it.toSeason()
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
}
library.id to media
BaseItemKind.EPISODE -> {
val episode = it.toEpisode(url)
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
}
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()
}
@@ -540,4 +404,4 @@ private data class HomeContentSnapshot(
val latestLibraryContent: Map<UUID, List<Media>>,
)
private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause)
private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause)

View File

@@ -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,82 +120,88 @@ 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) {
// 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(
seasons = currentSeries.seasons.map {
if (it.id == seasonId && it.episodes.isEmpty()) {
it.copy(episodes = episodes)
} else {
it
}
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")
}
)
current + (updatedSeries.id to updatedSeries)
// 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)
}
val serverUrl = userSessionRepository.serverUrl.first()
val episodes = jellyfinApiClient.getEpisodesInSeason(seriesId, seasonId)
.map { it.toEpisode(serverUrl) }
seriesState.update { current ->
val currentSeries = current[seriesId] ?: return@update current
val updatedSeries = currentSeries.copy(
seasons = currentSeries.seasons.map {
if (it.id == seasonId && it.episodes.isEmpty()) {
it.copy(episodes = episodes)
} else {
it
}
}
)
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
}
}
episodesState.update { current -> current + episodes.associateBy { it.id } }
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
if (durationMs <= 0) return

View File

@@ -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,10 +43,20 @@ 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(
id = id,

View File

@@ -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>()
}
try {
val response = api.userViewsApi.getUserViews(
userId = getUserId(),
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
includeHidden = false,
)
response.content.items
} catch (e: NotModifiedException) {
null
return@logRequest emptyList()
}
val response = api.userViewsApi.getUserViews(
userId = getUserId(),
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
includeHidden = false,
)
response.content.items
}
}
@@ -247,243 +238,91 @@ 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>()
}
try {
val getItemsRequest = GetItemsRequest(
userId = getUserId(),
enableImages = true,
parentId = libraryId,
fields = defaultItemFields + ItemFields.OVERVIEW,
enableUserData = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
)
val response = api.itemsApi.getItems(getItemsRequest)
response.content.items
} catch (e: NotModifiedException) {
null
return@logRequest emptyList()
}
val getItemsRequest = GetItemsRequest(
userId = getUserId(),
enableImages = true,
parentId = libraryId,
fields = defaultItemFields + ItemFields.OVERVIEW,
enableUserData = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
recursive = true,
)
val response = api.itemsApi.getItems(getItemsRequest)
response.content.items
}
}
/**
* 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>()
}
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
try {
val response = api.suggestionsApi.getSuggestions(
userId = userId,
mediaType = listOf(MediaType.VIDEO),
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
limit = 8,
enableTotalRecordCount = true,
)
response.content.items
} catch (e: NotModifiedException) {
null
return@logRequest emptyList()
}
val userId = getUserId() ?: return@logRequest emptyList()
val response = api.suggestionsApi.getSuggestions(
userId = userId,
mediaType = listOf(MediaType.VIDEO),
type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
limit = 8,
enableTotalRecordCount = true,
)
response.content.items
}
}
suspend fun getContinueWatching(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
suspend fun getContinueWatching(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("getContinueWatching") {
if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>()
}
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
try {
val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId,
fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
enableUserData = true,
startIndex = 0,
)
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
response.content.items
} catch (e: NotModifiedException) {
null
return@logRequest emptyList()
}
val userId = getUserId() ?: return@logRequest emptyList()
val getResumeItemsRequest = GetResumeItemsRequest(
userId = userId,
fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
enableUserData = true,
startIndex = 0,
)
val response: Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
response.content.items
}
}
suspend fun getNextUpEpisodes(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("getNextUpEpisodes") {
if (!ensureConfigured()) {
return@logRequest emptyList<BaseItemDto>()
}
try {
val getNextUpRequest = GetNextUpRequest(
userId = getUserId(),
fields = defaultItemFields + ItemFields.OVERVIEW,
enableResumable = false,
)
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
result.content.items
} catch (e: NotModifiedException) {
null
throw IllegalStateException("Not configured")
}
val getNextUpRequest = GetNextUpRequest(
userId = getUserId(),
fields = defaultItemFields + ItemFields.OVERVIEW,
enableResumable = false,
)
val result = api.tvShowsApi.getNextUp(getNextUpRequest)
result.content.items
}
}
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>()
}
try {
val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(),
parentId = libraryId,
fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
limit = 15,
)
response.content
} catch (e: NotModifiedException) {
null
return@logRequest emptyList()
}
val response = api.userLibraryApi.getLatestMedia(
userId = getUserId(),
parentId = libraryId,
fields = defaultItemFields + ItemFields.OVERVIEW,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON),
limit = 15,
)
response.content
}
}
/**
* 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
}
}
}
suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) {
logRequest("getItemInfo") {
if (!ensureConfigured()) {
@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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()

View File

@@ -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