mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
revert(data): remove ETag caching and head-fetch home refresh
Reverts5491c38(ETag-based conditional HTTP caching for home refresh) and1ec9ff9(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:
@@ -1,6 +1,5 @@
|
|||||||
package hu.bbara.purefin.core.data
|
package hu.bbara.purefin.core.data
|
||||||
|
|
||||||
import hu.bbara.purefin.core.model.MediaUiModel
|
|
||||||
import hu.bbara.purefin.model.Library
|
import hu.bbara.purefin.model.Library
|
||||||
import hu.bbara.purefin.model.Media
|
import hu.bbara.purefin.model.Media
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -14,15 +13,4 @@ interface HomeRepository {
|
|||||||
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
|
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
|
||||||
fun ensureReady()
|
fun ensureReady()
|
||||||
suspend fun refreshHomeData()
|
suspend fun refreshHomeData()
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the full content (movies or series) of a single library on
|
|
||||||
* demand. Used by the library detail screen; the home page no longer
|
|
||||||
* pays for full library content on every refresh.
|
|
||||||
*
|
|
||||||
* Returns `null` when the server returned 304 Not Modified for the
|
|
||||||
* per-library ETag — the caller should keep its previously fetched
|
|
||||||
* copy. An empty list is a legitimate "the library is empty" result.
|
|
||||||
*/
|
|
||||||
suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>?
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,17 +68,16 @@ class AppViewModel @Inject constructor(
|
|||||||
|
|
||||||
val libraries = homeRepository.libraries.map { libraries ->
|
val libraries = homeRepository.libraries.map { libraries ->
|
||||||
libraries.map {
|
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(
|
LibraryUiModel(
|
||||||
id = it.id,
|
id = it.id,
|
||||||
name = it.name,
|
name = it.name,
|
||||||
type = it.type,
|
type = it.type,
|
||||||
posterUrl = it.posterUrl,
|
posterUrl = it.posterUrl,
|
||||||
size = it.size,
|
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())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|||||||
@@ -6,13 +6,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import hu.bbara.purefin.core.data.HomeRepository
|
import hu.bbara.purefin.core.data.HomeRepository
|
||||||
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
import hu.bbara.purefin.core.data.MediaMetadataUpdater
|
||||||
import hu.bbara.purefin.core.model.MediaUiModel
|
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.MovieDto
|
||||||
import hu.bbara.purefin.core.navigation.NavigationManager
|
import hu.bbara.purefin.core.navigation.NavigationManager
|
||||||
import hu.bbara.purefin.core.navigation.Route
|
import hu.bbara.purefin.core.navigation.Route
|
||||||
import hu.bbara.purefin.core.navigation.SeriesDto
|
import hu.bbara.purefin.core.navigation.SeriesDto
|
||||||
|
import hu.bbara.purefin.model.LibraryKind
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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 kotlinx.coroutines.launch
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -26,35 +31,24 @@ class LibraryViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val selectedLibrary = MutableStateFlow<UUID?>(null)
|
private val selectedLibrary = MutableStateFlow<UUID?>(null)
|
||||||
|
|
||||||
// Local cache of the last-fetched content per library. Used to preserve
|
val contents: StateFlow<List<MediaUiModel>> = combine(selectedLibrary, homeRepository.libraries) {
|
||||||
// content when re-selecting a library whose ETag matches (so the
|
libraryId, libraries ->
|
||||||
// repository's 304 response can be treated as "use the cached copy").
|
if (libraryId == null) {
|
||||||
private val cachedContents = mutableMapOf<UUID, List<MediaUiModel>>()
|
return@combine emptyList()
|
||||||
|
}
|
||||||
private val _contents = MutableStateFlow<List<MediaUiModel>>(emptyList())
|
val library = libraries.find { it.id == libraryId } ?: return@combine emptyList()
|
||||||
val contents: StateFlow<List<MediaUiModel>> = _contents.asStateFlow()
|
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 {
|
init {
|
||||||
viewModelScope.launch { homeRepository.ensureReady() }
|
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) {
|
fun onMovieSelected(movieId: UUID) {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package hu.bbara.purefin.core.jellyfin
|
|
||||||
|
|
||||||
import javax.inject.Qualifier
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks an `OkHttpClient` that is wired into the Jellyfin SDK's `OkHttpFactory`.
|
|
||||||
* Distinct from the unqualified client used for image loading and media streaming
|
|
||||||
* because the SDK has its own auth path and the ETag interceptor should not leak
|
|
||||||
* into non-SDK HTTP calls.
|
|
||||||
*/
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class JellyfinSdkClient
|
|
||||||
@@ -5,9 +5,6 @@ import hu.bbara.purefin.core.concurrency.SingleFlight
|
|||||||
import hu.bbara.purefin.core.data.HomeRepository
|
import hu.bbara.purefin.core.data.HomeRepository
|
||||||
import hu.bbara.purefin.core.data.NetworkMonitor
|
import hu.bbara.purefin.core.data.NetworkMonitor
|
||||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
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.toEpisode
|
||||||
import hu.bbara.purefin.data.converter.toLibrary
|
import hu.bbara.purefin.data.converter.toLibrary
|
||||||
import hu.bbara.purefin.data.converter.toMovie
|
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.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import timber.log.Timber
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.concurrent.atomics.AtomicBoolean
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class InMemoryAppContentRepository @Inject constructor(
|
class InMemoryAppContentRepository @Inject constructor(
|
||||||
@@ -78,17 +75,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
|
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
|
||||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
|
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 {
|
init {
|
||||||
ensureReady()
|
ensureReady()
|
||||||
}
|
}
|
||||||
@@ -142,14 +128,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
private suspend fun loadHomeCache() {
|
private suspend fun loadHomeCache() {
|
||||||
Timber.tag(TAG).d("Loading home cache")
|
Timber.tag(TAG).d("Loading home cache")
|
||||||
val cache = homeCacheDataStore.data.first()
|
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()) {
|
if (cache.libraries.isNotEmpty()) {
|
||||||
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
|
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
|
||||||
librariesState.value = libraries
|
librariesState.value = libraries
|
||||||
@@ -192,12 +170,6 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
val movies = onlineMediaRepository.movies.value
|
val movies = onlineMediaRepository.movies.value
|
||||||
val series = onlineMediaRepository.series.value
|
val series = onlineMediaRepository.series.value
|
||||||
val episodes = onlineMediaRepository.episodes.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(
|
val cache = HomeCache(
|
||||||
suggestions = suggestionsState.value.map { it.toCachedItem() },
|
suggestions = suggestionsState.value.map { it.toCachedItem() },
|
||||||
continueWatching = continueWatchingState.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() }
|
uuid.toString() to items.map { it.toCachedItem() }
|
||||||
}.toMap(),
|
}.toMap(),
|
||||||
libraries = librariesState.value.map { it.toCachedLibrary() },
|
libraries = librariesState.value.map { it.toCachedLibrary() },
|
||||||
libraryDateLastMediaAdded = cachedLibraryDateLastMediaAdded.mapKeys { it.key.toString() },
|
|
||||||
movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() },
|
movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() },
|
||||||
series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() },
|
series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() },
|
||||||
episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() },
|
episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() },
|
||||||
@@ -246,97 +217,55 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
handleRefreshFailure(error, "Unable to load libraries")
|
handleRefreshFailure(error, "Unable to load libraries")
|
||||||
}
|
}
|
||||||
|
val filteredLibraries = librariesItem.filter {
|
||||||
// Build the new library list in a single pass, so collectors of
|
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
|
||||||
// `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 emptyLibraries = filteredLibraries.mapNotNull { it.toLibrary(serverUrl()) }
|
||||||
|
librariesState.value = emptyLibraries
|
||||||
|
|
||||||
|
val filledLibraries = emptyLibraries.map { loadLibrary(it) }
|
||||||
librariesState.value = filledLibraries
|
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>? =
|
private suspend fun loadLibrary(library: Library): Library {
|
||||||
singleFlight.run("AppContent:loadLibraryContent:$libraryId") {
|
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
||||||
val library = librariesState.value.find { it.id == libraryId } ?: return@run emptyList()
|
.getOrElse { error ->
|
||||||
// ETag hit — return null so the caller can keep its previously
|
handleRefreshFailure(error, "Unable to load library ${library.id}")
|
||||||
// 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)) }
|
|
||||||
}
|
}
|
||||||
|
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") {
|
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() }
|
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
handleRefreshFailure(error, "Unable to load suggestions")
|
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 ->
|
suggestionsItems.forEach { item ->
|
||||||
when (item.type) {
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
|
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||||
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
|
||||||
else -> {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||||
@@ -347,34 +276,21 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadContinueWatching() = singleFlight.run("AppContent:loadContinueWatching") {
|
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() }
|
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
handleRefreshFailure(error, "Unable to load continue watching")
|
handleRefreshFailure(error, "Unable to load continue watching")
|
||||||
}
|
}
|
||||||
if (continueWatchingItems == null) return@run
|
// Upsert full episode details BEFORE publishing the new row, so the
|
||||||
|
|
||||||
// Upsert full details BEFORE publishing the new row, so the
|
|
||||||
// home viewmodel's `combine` of this flow with the local media
|
// home viewmodel's `combine` of this flow with the local media
|
||||||
// repository never observes an empty intermediate state. If we
|
// repository never observes an empty intermediate state. If we
|
||||||
// assigned continueWatchingState first, the new episode/movie IDs
|
// assigned continueWatchingState first, the new episode IDs would
|
||||||
// would be unresolvable for a single emission and the Continue
|
// be unresolvable for a single emission and the Continue Watching
|
||||||
// Watching section would briefly drop from the home screen.
|
// section would briefly drop from the home screen.
|
||||||
continueWatchingItems.forEach { item ->
|
continueWatchingItems.forEach { item ->
|
||||||
when (item.type) {
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
|
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||||
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
|
||||||
else -> {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||||
@@ -385,30 +301,19 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadNextUp() = singleFlight.run("AppContent:loadNextUp") {
|
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() }
|
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
||||||
.getOrElse { error ->
|
.getOrElse { error ->
|
||||||
handleRefreshFailure(error, "Unable to load next up")
|
handleRefreshFailure(error, "Unable to load next up")
|
||||||
}
|
}
|
||||||
if (nextUpItems == null) return@run
|
// Upsert full episode details BEFORE publishing the new row, so the
|
||||||
|
|
||||||
// Upsert full details BEFORE publishing the new row, so the
|
|
||||||
// home viewmodel's `combine` of this flow with the local media
|
// home viewmodel's `combine` of this flow with the local media
|
||||||
// repository never observes an empty intermediate state. If we
|
// repository never observes an empty intermediate state. If we
|
||||||
// assigned nextUpState first, the new episode IDs would be
|
// assigned nextUpState first, the new episode IDs would be
|
||||||
// unresolvable for a single emission and the Next Up section
|
// unresolvable for a single emission and the Next Up section
|
||||||
// would briefly drop from the home screen.
|
// would briefly drop from the home screen.
|
||||||
nextUpItems.forEach { item ->
|
nextUpItems.forEach { item ->
|
||||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||||
}
|
}
|
||||||
|
|
||||||
nextUpState.value = nextUpItems.map { item ->
|
nextUpState.value = nextUpItems.map { item ->
|
||||||
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
||||||
}
|
}
|
||||||
@@ -420,62 +325,30 @@ class InMemoryAppContentRepository @Inject constructor(
|
|||||||
val filteredLibraries = librariesState.value
|
val filteredLibraries = librariesState.value
|
||||||
val url = serverUrl()
|
val url = serverUrl()
|
||||||
val latestLibraryContents = filteredLibraries.associate { library ->
|
val latestLibraryContents = filteredLibraries.associate { library ->
|
||||||
// Layer 1: skip when the server-reported "last media added"
|
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
||||||
// timestamp is unchanged since the last refresh. This is the
|
.getOrElse { error ->
|
||||||
// cheapest check (no request) and handles the common "no new
|
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
|
||||||
// content" case.
|
}
|
||||||
val cachedDate = cachedLibraryDateLastMediaAdded[library.id]
|
library.id to when (library.type) {
|
||||||
val currentDate = currentLibraryDateLastMediaAdded[library.id]
|
LibraryKind.MOVIES -> latestFromLibrary.map {
|
||||||
if (cachedDate != null && cachedDate == currentDate) {
|
val movie = it.toMovie(url)
|
||||||
library.id to (latestLibraryContentState.value[library.id] ?: emptyList())
|
Media.MovieMedia(movieId = movie.id)
|
||||||
} else {
|
}
|
||||||
// Layer 2: head-only fetch. If the first 2 items of the
|
LibraryKind.SERIES -> latestFromLibrary.map {
|
||||||
// latest row are the same as the cached head, the rest of
|
when (it.type) {
|
||||||
// the row is also unchanged (the home row only rotates at
|
BaseItemKind.SERIES -> {
|
||||||
// the top) and we can skip the full request.
|
val series = it.toSeries(url)
|
||||||
val headDtos = runCatching { jellyfinApiClient.getLatestFromLibraryHead(library.id) }
|
Media.SeriesMedia(seriesId = series.id)
|
||||||
.getOrElse { error ->
|
|
||||||
handleRefreshFailure(error, "Unable to load latest head for library ${library.id}")
|
|
||||||
}
|
|
||||||
val cachedSlice = latestLibraryContentState.value[library.id].orEmpty()
|
|
||||||
if (headDtos == null || headMatches(headDtos, cachedSlice)) {
|
|
||||||
library.id to cachedSlice
|
|
||||||
} else {
|
|
||||||
// Layer 3: head changed — fetch the full row.
|
|
||||||
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
|
||||||
.getOrElse { error ->
|
|
||||||
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
|
|
||||||
}
|
}
|
||||||
if (latestFromLibrary == null) {
|
BaseItemKind.SEASON -> {
|
||||||
library.id to cachedSlice
|
val season = it.toSeason()
|
||||||
} else {
|
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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
|
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 {
|
private suspend fun serverUrl(): String {
|
||||||
return userSessionRepository.serverUrl.first()
|
return userSessionRepository.serverUrl.first()
|
||||||
}
|
}
|
||||||
@@ -554,4 +404,4 @@ private data class HomeContentSnapshot(
|
|||||||
val latestLibraryContent: Map<UUID, List<Media>>,
|
val latestLibraryContent: Map<UUID, List<Media>>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause)
|
private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause)
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import hu.bbara.purefin.core.data.PlaybackMethod
|
|||||||
import hu.bbara.purefin.core.data.PlaybackReportContext
|
import hu.bbara.purefin.core.data.PlaybackReportContext
|
||||||
import hu.bbara.purefin.core.data.QuickConnectSession
|
import hu.bbara.purefin.core.data.QuickConnectSession
|
||||||
import hu.bbara.purefin.core.data.UserSessionRepository
|
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.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
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.userLibraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
|
||||||
import org.jellyfin.sdk.createJellyfin
|
import org.jellyfin.sdk.createJellyfin
|
||||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||||
@@ -69,12 +66,10 @@ import javax.inject.Singleton
|
|||||||
class JellyfinApiClient @Inject constructor(
|
class JellyfinApiClient @Inject constructor(
|
||||||
@ApplicationContext private val applicationContext: Context,
|
@ApplicationContext private val applicationContext: Context,
|
||||||
private val userSessionRepository: UserSessionRepository,
|
private val userSessionRepository: UserSessionRepository,
|
||||||
@JellyfinSdkClient private val okHttpFactory: OkHttpFactory,
|
|
||||||
) {
|
) {
|
||||||
private val jellyfin = createJellyfin {
|
private val jellyfin = createJellyfin {
|
||||||
context = applicationContext
|
context = applicationContext
|
||||||
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
|
clientInfo = ClientInfo(name = "Purefin", version = "0.0.1")
|
||||||
apiClientFactory = okHttpFactory
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val api = jellyfin.createApi()
|
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") {
|
logRequest("getLibraries") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
return@logRequest emptyList()
|
||||||
}
|
|
||||||
try {
|
|
||||||
val response = api.userViewsApi.getUserViews(
|
|
||||||
userId = getUserId(),
|
|
||||||
presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS),
|
|
||||||
includeHidden = false,
|
|
||||||
)
|
|
||||||
response.content.items
|
|
||||||
} catch (e: NotModifiedException) {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
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") {
|
logRequest("getLibraryContent") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
return@logRequest emptyList()
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
suspend fun getSuggestions(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||||
* 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) {
|
|
||||||
logRequest("getSuggestions") {
|
logRequest("getSuggestions") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
return@logRequest emptyList()
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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") {
|
logRequest("getContinueWatching") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
return@logRequest emptyList()
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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") {
|
logRequest("getNextUpEpisodes") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
throw IllegalStateException("Not configured")
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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") {
|
logRequest("getLatestFromLibrary") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
return@logRequest emptyList<BaseItemDto>()
|
return@logRequest emptyList()
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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) {
|
suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) {
|
||||||
logRequest("getItemInfo") {
|
logRequest("getItemInfo") {
|
||||||
if (!ensureConfigured()) {
|
if (!ensureConfigured()) {
|
||||||
@@ -796,9 +635,5 @@ class JellyfinApiClient @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "JellyfinApiClient"
|
private const val TAG = "JellyfinApiClient"
|
||||||
// How many items to fetch on the head-only path. Two is enough to
|
|
||||||
// detect the common case of "first card unchanged" without making
|
|
||||||
// the head request meaningfully heavier than the minimum.
|
|
||||||
const val HEAD_LIMIT = 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.etag
|
|
||||||
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory store of `ETag` response headers, keyed by full request URL. The
|
|
||||||
* [ETagInterceptor] reads from this store to add `If-None-Match` to outgoing
|
|
||||||
* requests and writes to it on every successful response.
|
|
||||||
*
|
|
||||||
* ETag handling is scoped to a small set of home-refresh URL patterns
|
|
||||||
* matched against path segments. All other endpoints (per-item lookups,
|
|
||||||
* search, sessions, system info, etc.) are passed through unchanged. This
|
|
||||||
* keeps the 304 → [NotModifiedException] translation from leaking into code
|
|
||||||
* paths that do not expect it, and prevents search and similar list-shaped
|
|
||||||
* calls from being treated as cacheable.
|
|
||||||
*
|
|
||||||
* The cache is process-scoped: ETags are lost on process death, which means
|
|
||||||
* the first request after each app start always goes out without
|
|
||||||
* `If-None-Match`. This is acceptable — the server returns the same data
|
|
||||||
* with a fresh ETag, and subsequent requests benefit from the cache.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ETagCache @Inject constructor() {
|
|
||||||
private val etags = ConcurrentHashMap<String, String>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns `true` if requests to [url] should use ETag-based conditional
|
|
||||||
* fetching. Only home-refresh endpoints are eligible, and the
|
|
||||||
* `/Items` list endpoint is only eligible when it is being used as a
|
|
||||||
* per-library content query (a `parentId` query parameter is present
|
|
||||||
* and no search-shaped parameters are set).
|
|
||||||
*/
|
|
||||||
fun isEligible(url: HttpUrl): Boolean = matchesHomeRefresh(url)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the cached ETag for [url], or `null` if none has been stored
|
|
||||||
* yet.
|
|
||||||
*/
|
|
||||||
fun get(url: String): String? = etags[url]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache [etag] for [url]. The interceptor writes here after every
|
|
||||||
* eligible 2xx response that carried an ETag header.
|
|
||||||
*/
|
|
||||||
fun put(url: String, etag: String) {
|
|
||||||
etags[url] = etag
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun matchesHomeRefresh(url: HttpUrl): Boolean {
|
|
||||||
val segments = url.pathSegments
|
|
||||||
return when {
|
|
||||||
// /Users/{userId}/Views — get libraries
|
|
||||||
segments.size == 3 && segments[0] == "Users" && segments[2] == "Views" -> true
|
|
||||||
// /Items/Suggestions — get suggestions
|
|
||||||
segments == listOf("Items", "Suggestions") -> true
|
|
||||||
// /Users/{userId}/Items/Resume — get resume items (continue watching)
|
|
||||||
segments.size == 4 &&
|
|
||||||
segments[0] == "Users" &&
|
|
||||||
segments[2] == "Items" &&
|
|
||||||
segments[3] == "Resume" -> true
|
|
||||||
// /Shows/NextUp — get next up episodes
|
|
||||||
segments == listOf("Shows", "NextUp") -> true
|
|
||||||
// /Users/{userId}/Items/Latest — latest items in a library
|
|
||||||
segments.size == 4 &&
|
|
||||||
segments[0] == "Users" &&
|
|
||||||
segments[2] == "Items" &&
|
|
||||||
segments[3] == "Latest" -> true
|
|
||||||
// /Items — per-library content list, distinguished from search
|
|
||||||
// by the presence of `parentId` and the absence of search params.
|
|
||||||
segments == listOf("Items") -> isPerLibraryItemsQuery(url)
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isPerLibraryItemsQuery(url: HttpUrl): Boolean {
|
|
||||||
// The per-library content call always sets parentId; the search
|
|
||||||
// calls set searchTerm or genres but never parentId. Matching
|
|
||||||
// on parentId alone is enough to keep the ETag scope tight.
|
|
||||||
return url.queryParameter("parentId") != null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.etag
|
|
||||||
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
import timber.log.Timber
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OkHttp interceptor that turns Jellyfin's ETag support into a
|
|
||||||
* "not modified" signal the SDK caller can act on.
|
|
||||||
*
|
|
||||||
* For URLs whose path is in [ETagCache.isEligible]:
|
|
||||||
* - If a cached ETag exists, the request is sent with `If-None-Match`.
|
|
||||||
* - If the server replies 304, the response is closed and a
|
|
||||||
* [NotModifiedException] is thrown so the caller can keep using its
|
|
||||||
* existing copy of the data.
|
|
||||||
* - If the server replies 2xx with an ETag header, the new ETag is cached.
|
|
||||||
*
|
|
||||||
* All other URLs are passed through untouched. This bounds the surface
|
|
||||||
* area of the 304 handling: only code paths that explicitly know about
|
|
||||||
* the home-refresh contract need to catch [NotModifiedException].
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ETagInterceptor @Inject constructor(
|
|
||||||
private val cache: ETagCache,
|
|
||||||
) : Interceptor {
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
|
|
||||||
if (!cache.isEligible(request.url)) {
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
val url = request.url.toString()
|
|
||||||
val etag = cache.get(url)
|
|
||||||
val outgoing = if (etag != null) {
|
|
||||||
request.newBuilder().header(HEADER_IF_NONE_MATCH, etag).build()
|
|
||||||
} else {
|
|
||||||
request
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = chain.proceed(outgoing)
|
|
||||||
|
|
||||||
if (response.code == HTTP_NOT_MODIFIED) {
|
|
||||||
// Close before throwing so OkHttp does not warn about leaked
|
|
||||||
// responses. The caller will treat this as "use the cached copy".
|
|
||||||
response.close()
|
|
||||||
Timber.tag(TAG).d("ETag hit for %s", url)
|
|
||||||
throw NotModifiedException()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the ETag for next time. Only successful responses carry
|
|
||||||
// meaningful ETags; error responses may carry caching hints but
|
|
||||||
// we should not treat a 5xx body as a valid cache key.
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
response.header(HEADER_ETAG)?.let { newEtag ->
|
|
||||||
cache.put(url, newEtag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "ETagInterceptor"
|
|
||||||
const val HEADER_ETAG = "ETag"
|
|
||||||
const val HEADER_IF_NONE_MATCH = "If-None-Match"
|
|
||||||
const val HTTP_NOT_MODIFIED = 304
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.etag
|
|
||||||
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides the [OkHttpClient] (with [ETagInterceptor] installed) and the
|
|
||||||
* [OkHttpFactory] used by the Jellyfin SDK. The client is qualified with
|
|
||||||
* [JellyfinSdkClient] so it is distinct from the unqualified OkHttpClient
|
|
||||||
* in `core.jellyfin.JellyfinNetworkModule`, which is used for image and
|
|
||||||
* media streaming. The Jellyfin SDK provides its own auth header on every
|
|
||||||
* request, so the SDK-bound client does not need `JellyfinAuthInterceptor`.
|
|
||||||
*/
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object JellyfinOkHttpModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@JellyfinSdkClient
|
|
||||||
fun provideJellyfinSdkOkHttpClient(etagInterceptor: ETagInterceptor): OkHttpClient =
|
|
||||||
OkHttpClient.Builder()
|
|
||||||
.addInterceptor(etagInterceptor)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@JellyfinSdkClient
|
|
||||||
fun provideJellyfinOkHttpFactory(@JellyfinSdkClient client: OkHttpClient): OkHttpFactory =
|
|
||||||
OkHttpFactory(base = client)
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package hu.bbara.purefin.data.jellyfin.etag
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown by [ETagInterceptor] when the Jellyfin server returns 304 Not Modified
|
|
||||||
* for a request that had an `If-None-Match` header. Callers should treat this
|
|
||||||
* as "the cached copy is still valid" and avoid re-processing the response.
|
|
||||||
*
|
|
||||||
* This is a control-flow signal, not an error — it propagates out of
|
|
||||||
* `OkHttpClient.await()` before the SDK can deserialize a 304 body. Only
|
|
||||||
* URLs that have been registered in [ETagCache] can produce this exception.
|
|
||||||
*/
|
|
||||||
class NotModifiedException : RuntimeException("ETag match — server returned 304 Not Modified")
|
|
||||||
@@ -91,7 +91,6 @@ data class HomeCache(
|
|||||||
val nextUp: List<CachedMediaItem> = emptyList(),
|
val nextUp: List<CachedMediaItem> = emptyList(),
|
||||||
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap(),
|
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap(),
|
||||||
val libraries: List<CachedLibrary> = emptyList(),
|
val libraries: List<CachedLibrary> = emptyList(),
|
||||||
val libraryDateLastMediaAdded: Map<String, String> = emptyMap(),
|
|
||||||
val movies: List<CachedMovie> = emptyList(),
|
val movies: List<CachedMovie> = emptyList(),
|
||||||
val series: List<CachedSeries> = emptyList(),
|
val series: List<CachedSeries> = emptyList(),
|
||||||
val episodes: List<CachedEpisode> = emptyList()
|
val episodes: List<CachedEpisode> = emptyList()
|
||||||
|
|||||||
Reference in New Issue
Block a user