perf(home): reduce redundant requests on home refresh

Drop a duplicate getLibraries call from loadLatestLibraryContent by
reusing the libraries already loaded earlier in the same refresh.

Add a 30s debounce to HomeRefreshCoordinator.onResumed so rapid
resume/tab-switch events coalesce into a single refresh; pull-to-refresh
bypasses the gate via onRefresh.

Rate-limit SyncPlaybackPositionsHomeRefreshSideEffect to once every
6h with a 25-item cap and a rotating window cursor, so users with many
downloads no longer fire one getItemInfo call per item on every resume.
This commit is contained in:
2026-06-19 12:34:04 +00:00
parent 54c895ff0f
commit f6c465656e
3 changed files with 88 additions and 19 deletions

View File

@@ -1,27 +1,56 @@
package hu.bbara.purefin.core.feature.browse.home.refresh
import android.os.SystemClock
import hu.bbara.purefin.core.data.HomeRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.concurrent.atomics.AtomicLong
import kotlin.concurrent.atomics.ExperimentalAtomicApi
// Singleton so the debounce window is shared across all ViewModel instances.
// A new coordinator per ViewModel would reset the timer on configuration
// changes and defeat the purpose of coalescing rapid resume/tab-switch
// events.
@Singleton
class HomeRefreshCoordinator @Inject constructor(
private val homeRepository: HomeRepository,
private val sideEffects: Set<@JvmSuppressWildcards HomeRefreshSideEffect>,
) {
private val sideEffectsMutex = Mutex()
// AtomicLong matches the rate-limit pattern used by the playback-position
// side effect and is safe to read/write from any coroutine dispatcher.
@OptIn(ExperimentalAtomicApi::class)
private val lastRefreshAtMs = AtomicLong(0L)
@OptIn(ExperimentalAtomicApi::class)
suspend fun onResumed() {
val now = SystemClock.elapsedRealtime()
if (now - lastRefreshAtMs.load() < RESUME_DEBOUNCE_MS) {
Timber.tag(TAG).d("Skipping onResumed refresh (debounced)")
return
}
refreshHomeData()
runSideEffects()
// Updated unconditionally on completion. refreshHomeData() catches
// non-cancellation exceptions internally, so a failed refresh still
// counts as "we just touched the server" and the next onResumed
// within RESUME_DEBOUNCE_MS will be skipped. Pull-to-refresh always
// bypasses this gate via onRefresh.
lastRefreshAtMs.store(SystemClock.elapsedRealtime())
}
@OptIn(ExperimentalAtomicApi::class)
suspend fun onRefresh(setRefreshing: (Boolean) -> Unit) {
refreshHomeData(setRefreshing)
runSideEffects()
lastRefreshAtMs.store(SystemClock.elapsedRealtime())
}
private suspend fun refreshHomeData(
@@ -54,4 +83,11 @@ class HomeRefreshCoordinator @Inject constructor(
}
}
}
private companion object {
const val TAG = "HomeRefreshCoordinator"
// Coalesce rapid resume/tab-switch events into a single refresh.
// Pull-to-refresh bypasses this gate via onRefresh.
const val RESUME_DEBOUNCE_MS = 30_000L
}
}

View File

@@ -303,27 +303,24 @@ class InMemoryAppContentRepository @Inject constructor(
}
private suspend fun loadLatestLibraryContent() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load latest library content")
}
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
}
// Reuse the libraries already loaded by loadLibraries() so we don't refetch
// the same /Users/{userId}/Views response a second time per refresh.
val filteredLibraries = librariesState.value
val url = serverUrl()
val latestLibraryContents = filteredLibraries.associate { library ->
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
.getOrElse { error ->
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
}
library.id to when (library.collectionType) {
CollectionType.MOVIES -> latestFromLibrary.map {
val movie = it.toMovie(serverUrl())
library.id to when (library.type) {
LibraryKind.MOVIES -> latestFromLibrary.map {
val movie = it.toMovie(url)
Media.MovieMedia(movieId = movie.id)
}
CollectionType.TVSHOWS -> latestFromLibrary.map {
LibraryKind.SERIES -> latestFromLibrary.map {
when (it.type) {
BaseItemKind.SERIES -> {
val series = it.toSeries(serverUrl())
val series = it.toSeries(url)
Media.SeriesMedia(seriesId = series.id)
}
BaseItemKind.SEASON -> {
@@ -331,13 +328,12 @@ class InMemoryAppContentRepository @Inject constructor(
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
}
BaseItemKind.EPISODE -> {
val episode = it.toEpisode(serverUrl())
val episode = it.toEpisode(url)
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
}
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
}
}
else -> throw UnsupportedOperationException("Unsupported library type: ${library.collectionType}")
}
}
latestLibraryContentState.value = latestLibraryContents

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.data.jellyfin.playback
import android.os.SystemClock
import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.Online
import hu.bbara.purefin.core.data.LocalMediaRepository
@@ -15,6 +16,9 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.concurrent.atomics.AtomicLong
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.fetchAndIncrement
import kotlin.math.roundToLong
class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor(
@@ -25,17 +29,44 @@ class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor(
@param:Online private val onlineRepository: LocalMediaRepository,
) : HomeRefreshSideEffect {
// Tracks the last time the sync actually ran. Using an AtomicLong keeps the
// timestamp race-free across coroutines; the debounce compares against
// SystemClock.elapsedRealtime() and skips when the window has not elapsed.
@OptIn(ExperimentalAtomicApi::class)
private val lastRunAtElapsedMs = AtomicLong(0L)
// Rotates the slice of items synced on each successful run. Without this,
// sortedBy+take(25) would re-fetch the same lexicographically lowest UUIDs
// forever and the rest of the offline library would never be reconciled.
@OptIn(ExperimentalAtomicApi::class)
private val nextWindowIndex = AtomicLong(0L)
@OptIn(ExperimentalAtomicApi::class)
override suspend fun run() {
if (!networkMonitor.isOnline.first()) return
val localPlaybackPositions = buildList {
addAll(offlineRepository.movies.first().values.map { it.toLocalPlaybackPosition() })
addAll(offlineRepository.episodes.first().values.map { it.toLocalPlaybackPosition() })
val now = SystemClock.elapsedRealtime()
if (now - lastRunAtElapsedMs.load() < MIN_INTERVAL_MS) {
Timber.tag(TAG).d("Skipping playback position sync (rate-limited)")
return
}
localPlaybackPositions.forEach { localPlaybackPosition ->
syncPlaybackPosition(localPlaybackPosition)
val sorted = buildList {
addAll(offlineRepository.movies.first().values.map { it.toLocalPlaybackPosition() })
addAll(offlineRepository.episodes.first().values.map { it.toLocalPlaybackPosition() })
}.sortedBy { it.mediaId }
val total = sorted.size
if (total > 0) {
val windowCount = (total + MAX_PER_RUN - 1) / MAX_PER_RUN
val windowIndex = Math.floorMod(nextWindowIndex.fetchAndIncrement(), windowCount.toLong()).toInt()
val start = windowIndex * MAX_PER_RUN
val end = minOf(start + MAX_PER_RUN, total)
val window = sorted.subList(start, end)
window.forEach { syncPlaybackPosition(it) }
}
lastRunAtElapsedMs.store(SystemClock.elapsedRealtime())
}
private suspend fun syncPlaybackPosition(localPlaybackPosition: LocalPlaybackPosition) {
@@ -151,5 +182,11 @@ class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor(
private companion object {
const val TAG = "PlaybackPositionSync"
const val PROGRESS_TOLERANCE_PERCENT = 0.5
// At most one full sweep every 6 hours; combined with MAX_PER_RUN this
// bounds the request count regardless of how many downloads the user has.
const val MIN_INTERVAL_MS = 6L * 60L * 60L * 1000L
// Cap the number of getItemInfo calls per refresh so a user with many
// downloads does not trigger N requests on every resume.
const val MAX_PER_RUN = 25
}
}