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:
2026-06-19 13:46:21 +00:00
parent 5491c381a3
commit 1ec9ff9c95
5 changed files with 346 additions and 118 deletions

View File

@@ -1,5 +1,6 @@
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
@@ -13,4 +14,15 @@ 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>?
} }

View File

@@ -68,16 +68,17 @@ 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 = when (it.type) { isEmpty = it.size == 0,
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())

View File

@@ -6,18 +6,13 @@ 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.combine import kotlinx.coroutines.flow.asStateFlow
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
@@ -31,24 +26,35 @@ class LibraryViewModel @Inject constructor(
private val selectedLibrary = MutableStateFlow<UUID?>(null) private val selectedLibrary = MutableStateFlow<UUID?>(null)
val contents: StateFlow<List<MediaUiModel>> = combine(selectedLibrary, homeRepository.libraries) { // Local cache of the last-fetched content per library. Used to preserve
libraryId, libraries -> // content when re-selecting a library whose ETag matches (so the
if (libraryId == null) { // repository's 304 response can be treated as "use the cached copy").
return@combine emptyList() private val cachedContents = mutableMapOf<UUID, List<MediaUiModel>>()
}
val library = libraries.find { it.id == libraryId } ?: return@combine emptyList() private val _contents = MutableStateFlow<List<MediaUiModel>>(emptyList())
when (library.type) { val contents: StateFlow<List<MediaUiModel>> = _contents.asStateFlow()
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) {

View File

@@ -4,6 +4,9 @@ import androidx.datastore.core.DataStore
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
@@ -34,14 +37,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 org.jellyfin.sdk.model.api.CollectionType import timber.log.Timber
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(
@@ -241,73 +244,88 @@ class InMemoryAppContentRepository @Inject constructor(
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load libraries") 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, // Build the new library list in a single pass, so collectors of
// but per-library content is a separate query with its own ETag, so // `librariesState` only ever see the final value. When /UserViews
// we still need to call getLibraryContent for each library to pick // returns 304 we already have the BaseItemDto list in memory, so
// up content additions that the libraries endpoint doesn't surface. // we just rebuild from it. Otherwise we have the new DTOs.
val librariesToProcess: List<Library> = if (librariesItem == null) { val filledLibraries: List<Library> = if (librariesItem == null) {
librariesState.value // ETag 304 on /UserViews — re-fetch the count per library
} else { // against the existing in-memory state. The size fallback
val filtered = librariesItem.filter { // (`?: library.size`) handles a 304 on /Items too, so a
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS // 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 } else {
// library for this refresh. loadLatestLibraryContent reads // Capture the server-reported "last media added" timestamp
// this map to decide which libraries need a /Items/Latest call, // per library for this refresh. loadLatestLibraryContent
// and persistHomeCache writes it to HomeCache as the new // reads this map to decide which libraries need a
// "cached" baseline for the next refresh. // /Items/Latest call, and persistHomeCache writes it to
currentLibraryDateLastMediaAdded = filtered // HomeCache as the new "cached" baseline.
currentLibraryDateLastMediaAdded = librariesItem
.mapNotNull { dto -> .mapNotNull { dto ->
dto.dateLastMediaAdded?.let { dto.id to it.toString() } dto.dateLastMediaAdded?.let { dto.id to it.toString() }
} }
.toMap() .toMap()
val emptyLibraries = filtered.map { it.toLibrary(serverUrl()) }
librariesState.value = emptyLibraries // Count-only refresh: ask the server for the number of items
emptyLibraries // 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 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 { override suspend fun loadLibraryContent(libraryId: UUID): List<MediaUiModel>? {
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) } val library = librariesState.value.find { it.id == libraryId } ?: return 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.
// ETag hit — the library's content is unchanged. Return the original val items = jellyfinApiClient.getLibraryContent(libraryId) ?: return null
// library so the existing movies/series/size stay in state. val url = serverUrl()
if (contentItem == null) {
return library
}
return when (library.type) { return when (library.type) {
LibraryKind.MOVIES -> library.copy( LibraryKind.MOVIES -> items.map { MovieUiModel(it.toMovie(url)) }
movies = contentItem.map { it.toMovie(serverUrl()) }, LibraryKind.SERIES -> items.map { SeriesUiModel(it.toSeries(url)) }
size = contentItem.size,
)
LibraryKind.SERIES -> library.copy(
series = contentItem.map { it.toSeries(serverUrl()) },
size = contentItem.size,
)
} }
} }
private suspend fun loadSuggestions() { 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() } val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load suggestions") 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 -> 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)
@@ -316,22 +334,31 @@ class InMemoryAppContentRepository @Inject constructor(
} }
} }
// Upsert full details so the home viewmodel can look up each item.
suggestionsItems.forEach { item -> suggestionsItems.forEach { item ->
if (item.type == BaseItemKind.EPISODE) { when (item.type) {
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl()))) BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
else -> {}
} }
} }
} }
private suspend fun loadContinueWatching() { 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() } val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load continue watching") 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 -> 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)
@@ -341,27 +368,35 @@ class InMemoryAppContentRepository @Inject constructor(
} }
continueWatchingItems.forEach { item -> continueWatchingItems.forEach { item ->
if (item.type == BaseItemKind.EPISODE) { when (item.type) {
onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl()))) BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url)))
BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url)))
else -> {}
} }
} }
} }
private suspend fun loadNextUp() { 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() } val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load next up") 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 -> nextUpState.value = nextUpItems.map { item ->
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!) Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
} }
nextUpItems.forEach { item -> 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 filteredLibraries = librariesState.value
val url = serverUrl() val url = serverUrl()
val latestLibraryContents = filteredLibraries.associate { library -> val latestLibraryContents = filteredLibraries.associate { library ->
// Skip the per-library GET when the server-reported "last media added" // Layer 1: skip when the server-reported "last media added"
// timestamp is identical to the one we saw last refresh. This is // timestamp is unchanged since the last refresh. This is the
// Jellyfin's stable signal that no new content was added, and it lets // cheapest check (no request) and handles the common "no new
// us avoid one request per library on the common case. ETag on // content" case.
// /Items/Latest remains the fallback for changes that don't bump the
// timestamp (e.g. metadata refreshes that don't add media).
val cachedDate = cachedLibraryDateLastMediaAdded[library.id] val cachedDate = cachedLibraryDateLastMediaAdded[library.id]
val currentDate = currentLibraryDateLastMediaAdded[library.id] val currentDate = currentLibraryDateLastMediaAdded[library.id]
if (cachedDate != null && cachedDate == currentDate) { if (cachedDate != null && cachedDate == currentDate) {
library.id to (latestLibraryContentState.value[library.id] ?: emptyList()) library.id to (latestLibraryContentState.value[library.id] ?: emptyList())
} else { } 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 -> .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. val cachedSlice = latestLibraryContentState.value[library.id].orEmpty()
if (latestFromLibrary == null) { if (headDtos == null || headMatches(headDtos, cachedSlice)) {
library.id to (latestLibraryContentState.value[library.id] ?: emptyList()) library.id to cachedSlice
} else { } else {
library.id to when (library.type) { // Layer 3: head changed — fetch the full row.
LibraryKind.MOVIES -> latestFromLibrary.map { val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
val movie = it.toMovie(url) .getOrElse { error ->
Media.MovieMedia(movieId = movie.id) handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
} }
LibraryKind.SERIES -> latestFromLibrary.map { if (latestFromLibrary == null) {
when (it.type) { library.id to cachedSlice
BaseItemKind.SERIES -> { } else {
val series = it.toSeries(url) val media = when (library.type) {
Media.SeriesMedia(seriesId = series.id) 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 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()
} }

View File

@@ -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) { suspend fun getSuggestions(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
logRequest("getSuggestions") { logRequest("getSuggestions") {
if (!ensureConfigured()) { 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) { suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) {
logRequest("getItemInfo") { logRequest("getItemInfo") {
if (!ensureConfigured()) { if (!ensureConfigured()) {
@@ -664,5 +796,9 @@ 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
} }
} }