mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-22 17:41:39 +00:00
perf(home): head-fetch rows and count-only libraries
Add a head-only fetch path for the home rows (Suggestions, Continue Watching, Next Up, Latest per library). The first HEAD_LIMIT items are fetched first; if the head matches the cached head, the full request is skipped. Most home refreshes now only pay for the small head request, not the full payload. Replace per-library getLibraryContent calls in the home page with getLibraryItemCount (limit=0, enableTotalRecordCount=true). The home page only uses Library.size; the full movies/series lists are fetched on demand by LibraryViewModel via the new loadLibraryContent method. The per-row loaders now upsert full movie/series details into onlineMediaRepository so the home viewmodel can still resolve them.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -13,4 +14,15 @@ interface HomeRepository {
|
||||
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
|
||||
fun ensureReady()
|
||||
suspend fun refreshHomeData()
|
||||
|
||||
/**
|
||||
* Fetches the full content (movies or series) of a single library on
|
||||
* demand. Used by the library detail screen; the home page no longer
|
||||
* pays for full library content on every refresh.
|
||||
*
|
||||
* Returns `null` when the server returned 304 Not Modified for the
|
||||
* per-library ETag — the caller should keep its previously fetched
|
||||
* copy. An empty list is a legitimate "the library is empty" result.
|
||||
*/
|
||||
suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>?
|
||||
}
|
||||
|
||||
@@ -68,16 +68,17 @@ 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 = when (it.type) {
|
||||
LibraryKind.MOVIES -> localMediaRepository.movies.value.isEmpty()
|
||||
LibraryKind.SERIES -> localMediaRepository.series.value.isEmpty()
|
||||
}
|
||||
isEmpty = it.size == 0,
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
@@ -6,18 +6,13 @@ 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.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -31,24 +26,35 @@ class LibraryViewModel @Inject constructor(
|
||||
|
||||
private val selectedLibrary = MutableStateFlow<UUID?>(null)
|
||||
|
||||
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())
|
||||
// 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()
|
||||
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,9 @@ import androidx.datastore.core.DataStore
|
||||
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
|
||||
@@ -34,14 +37,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 org.jellyfin.sdk.model.api.CollectionType
|
||||
import timber.log.Timber
|
||||
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(
|
||||
@@ -241,73 +244,88 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load libraries")
|
||||
}
|
||||
// Decide which libraries to refresh content for. When the libraries
|
||||
// endpoint returns 304 (ETag hit), the library list hasn't changed,
|
||||
// but per-library content is a separate query with its own ETag, so
|
||||
// we still need to call getLibraryContent for each library to pick
|
||||
// up content additions that the libraries endpoint doesn't surface.
|
||||
val librariesToProcess: List<Library> = if (librariesItem == null) {
|
||||
librariesState.value
|
||||
} else {
|
||||
val filtered = librariesItem.filter {
|
||||
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// 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 for the next refresh.
|
||||
currentLibraryDateLastMediaAdded = filtered
|
||||
} 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()
|
||||
val emptyLibraries = filtered.map { it.toLibrary(serverUrl()) }
|
||||
librariesState.value = emptyLibraries
|
||||
emptyLibraries
|
||||
|
||||
// Count-only refresh: ask the server for the number of items
|
||||
// in each library instead of enumerating them. The home
|
||||
// dashboard only uses Library.size for the library cards;
|
||||
// the full movies/series lists are fetched on demand by the
|
||||
// library detail screen via loadLibraryContent, and on the
|
||||
// home rows by loadSuggestions / loadContinueWatching /
|
||||
// loadLatestLibraryContent.
|
||||
librariesItem.map { dto ->
|
||||
val library = dto.toLibrary(serverUrl())
|
||||
val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size
|
||||
when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count)
|
||||
LibraryKind.SERIES -> library.copy(series = emptyList(), size = count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filledLibraries = librariesToProcess.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)
|
||||
}
|
||||
|
||||
private suspend fun loadLibrary(library: Library): Library {
|
||||
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load library ${library.id}")
|
||||
}
|
||||
// ETag hit — the library's content is unchanged. Return the original
|
||||
// library so the existing movies/series/size stay in state.
|
||||
if (contentItem == null) {
|
||||
return library
|
||||
}
|
||||
override suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>? {
|
||||
val library = librariesState.value.find { it.id == libraryId } ?: return emptyList()
|
||||
// ETag hit — return null so the caller can keep its previously
|
||||
// fetched copy. The library detail viewmodel uses this signal to
|
||||
// preserve its in-memory cache across re-selections.
|
||||
val items = jellyfinApiClient.getLibraryContent(libraryId) ?: return null
|
||||
val url = serverUrl()
|
||||
return when (library.type) {
|
||||
LibraryKind.MOVIES -> library.copy(
|
||||
movies = contentItem.map { it.toMovie(serverUrl()) },
|
||||
size = contentItem.size,
|
||||
)
|
||||
LibraryKind.SERIES -> library.copy(
|
||||
series = contentItem.map { it.toSeries(serverUrl()) },
|
||||
size = contentItem.size,
|
||||
)
|
||||
LibraryKind.MOVIES -> items.map { MovieUiModel(it.toMovie(url)) }
|
||||
LibraryKind.SERIES -> items.map { SeriesUiModel(it.toSeries(url)) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadSuggestions() {
|
||||
val url = serverUrl()
|
||||
// Head-first fetch: ask the server for just the first 2 items and
|
||||
// compare to the cached head. If the head is unchanged, the rest
|
||||
// of the row is also unchanged (the home screen shows the head
|
||||
// prominently and only rotates when new content arrives at the
|
||||
// top), so we can skip the full request entirely.
|
||||
val headItems = runCatching { jellyfinApiClient.getSuggestionsHead() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load suggestions head")
|
||||
}
|
||||
if (headItems == null) return
|
||||
if (headMatches(headItems, suggestionsState.value)) return
|
||||
|
||||
// Head changed (or first refresh) — fetch the full list.
|
||||
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load suggestions")
|
||||
}
|
||||
// ETag hit — suggestions are unchanged, keep the existing state.
|
||||
if (suggestionsItems == null) {
|
||||
return
|
||||
}
|
||||
if (suggestionsItems == null) return
|
||||
|
||||
suggestionsState.value = suggestionsItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||
@@ -316,22 +334,31 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert full details so the home viewmodel can look up each item.
|
||||
suggestionsItems.forEach { item ->
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
|
||||
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadContinueWatching() {
|
||||
val url = serverUrl()
|
||||
val headItems = runCatching { jellyfinApiClient.getContinueWatchingHead() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load continue watching head")
|
||||
}
|
||||
if (headItems == null) return
|
||||
if (headMatches(headItems, continueWatchingState.value)) return
|
||||
|
||||
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load continue watching")
|
||||
}
|
||||
// ETag hit — continue watching is unchanged, keep the existing state.
|
||||
if (continueWatchingItems == null) {
|
||||
return
|
||||
}
|
||||
if (continueWatchingItems == null) return
|
||||
|
||||
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
|
||||
@@ -341,27 +368,35 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
}
|
||||
|
||||
continueWatchingItems.forEach { item ->
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
|
||||
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadNextUp() {
|
||||
val url = serverUrl()
|
||||
val headItems = runCatching { jellyfinApiClient.getNextUpHead() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load next up head")
|
||||
}
|
||||
if (headItems == null) return
|
||||
if (headMatches(headItems, nextUpState.value)) return
|
||||
|
||||
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
|
||||
.getOrElse { error ->
|
||||
handleRefreshFailure(error, "Unable to load next up")
|
||||
}
|
||||
// ETag hit — next up is unchanged, keep the existing state.
|
||||
if (nextUpItems == null) {
|
||||
return
|
||||
}
|
||||
if (nextUpItems == null) return
|
||||
|
||||
nextUpState.value = nextUpItems.map { item ->
|
||||
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
|
||||
}
|
||||
|
||||
nextUpItems.forEach { item ->
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl())))
|
||||
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,47 +406,62 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
val filteredLibraries = librariesState.value
|
||||
val url = serverUrl()
|
||||
val latestLibraryContents = filteredLibraries.associate { library ->
|
||||
// Skip the per-library GET when the server-reported "last media added"
|
||||
// timestamp is identical to the one we saw last refresh. This is
|
||||
// Jellyfin's stable signal that no new content was added, and it lets
|
||||
// us avoid one request per library on the common case. ETag on
|
||||
// /Items/Latest remains the fallback for changes that don't bump the
|
||||
// timestamp (e.g. metadata refreshes that don't add media).
|
||||
// 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 {
|
||||
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
|
||||
// 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 items for library ${library.id}")
|
||||
handleRefreshFailure(error, "Unable to load latest head for library ${library.id}")
|
||||
}
|
||||
// ETag hit — /Items/Latest is unchanged, keep the previous slice.
|
||||
if (latestFromLibrary == null) {
|
||||
library.id to (latestLibraryContentState.value[library.id] ?: emptyList())
|
||||
val cachedSlice = latestLibraryContentState.value[library.id].orEmpty()
|
||||
if (headDtos == null || headMatches(headDtos, cachedSlice)) {
|
||||
library.id to cachedSlice
|
||||
} else {
|
||||
library.id to when (library.type) {
|
||||
LibraryKind.MOVIES -> latestFromLibrary.map {
|
||||
val movie = it.toMovie(url)
|
||||
Media.MovieMedia(movieId = movie.id)
|
||||
// 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}")
|
||||
}
|
||||
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)
|
||||
}
|
||||
BaseItemKind.EPISODE -> {
|
||||
val episode = it.toEpisode(url)
|
||||
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
|
||||
}
|
||||
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
|
||||
}
|
||||
}
|
||||
library.id to media
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,6 +469,29 @@ 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()
|
||||
}
|
||||
|
||||
@@ -270,6 +270,39 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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") {
|
||||
if (!ensureConfigured()) {
|
||||
@@ -352,6 +385,105 @@ class JellyfinApiClient @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()) {
|
||||
@@ -664,5 +796,9 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user