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).
This commit is contained in:
2026-06-26 15:44:05 +00:00
parent 13bc9410b4
commit c2cc4e9e65
11 changed files with 155 additions and 707 deletions

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

@@ -5,9 +5,6 @@ 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
@@ -38,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(
@@ -78,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()
}
@@ -142,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
@@ -192,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() },
@@ -206,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() },
@@ -246,97 +217,55 @@ class InMemoryAppContentRepository @Inject constructor(
.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.mapNotNull { dto ->
val library = dto.toLibrary(serverUrl()) ?: return@mapNotNull null
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>? =
singleFlight.run("AppContent:loadLibraryContent:$libraryId") {
val library = librariesState.value.find { it.id == libraryId } ?: return@run 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@run null
val url = serverUrl()
when (library.type) {
LibraryKind.MOVIES -> items.map { MovieUiModel(it.toMovie(url)) }
LibraryKind.SERIES -> items.map { SeriesUiModel(it.toSeries(url)) }
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 -> 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() = singleFlight.run("AppContent: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@run
if (headMatches(headItems, suggestionsState.value)) return@run
// Head changed (or first refresh) — fetch the full list.
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load suggestions")
}
if (suggestionsItems == null) return@run
// 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 ->
when (item.type) {
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
else -> {}
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)
@@ -347,34 +276,21 @@ class InMemoryAppContentRepository @Inject constructor(
}
private suspend fun loadContinueWatching() = singleFlight.run("AppContent:loadContinueWatching") {
val url = serverUrl()
val headItems = runCatching { jellyfinApiClient.getContinueWatchingHead() }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load continue watching head")
}
if (headItems == null) return@run
if (headMatches(headItems, continueWatchingState.value)) return@run
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load continue watching")
}
if (continueWatchingItems == null) return@run
// Upsert full details BEFORE publishing the new row, so the
// 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/movie IDs
// would be unresolvable for a single emission and the Continue
// Watching section would briefly drop from the home screen.
// 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 ->
when (item.type) {
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
else -> {}
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)
@@ -385,30 +301,19 @@ class InMemoryAppContentRepository @Inject constructor(
}
private suspend fun loadNextUp() = singleFlight.run("AppContent:loadNextUp") {
val url = serverUrl()
val headItems = runCatching { jellyfinApiClient.getNextUpHead() }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load next up head")
}
if (headItems == null) return@run
if (headMatches(headItems, nextUpState.value)) return@run
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load next up")
}
if (nextUpItems == null) return@run
// Upsert full details BEFORE publishing the new row, so the
// 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(url)))
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
}
nextUpState.value = nextUpItems.map { item ->
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
}
@@ -420,62 +325,30 @@ class InMemoryAppContentRepository @Inject constructor(
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}")
}
}
}
@@ -483,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()
}
@@ -554,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

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