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 package hu.bbara.purefin.core.feature.browse.home.refresh
import android.os.SystemClock
import hu.bbara.purefin.core.data.HomeRepository import hu.bbara.purefin.core.data.HomeRepository
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject 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( class HomeRefreshCoordinator @Inject constructor(
private val homeRepository: HomeRepository, private val homeRepository: HomeRepository,
private val sideEffects: Set<@JvmSuppressWildcards HomeRefreshSideEffect>, private val sideEffects: Set<@JvmSuppressWildcards HomeRefreshSideEffect>,
) { ) {
private val sideEffectsMutex = Mutex() 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() { suspend fun onResumed() {
val now = SystemClock.elapsedRealtime()
if (now - lastRefreshAtMs.load() < RESUME_DEBOUNCE_MS) {
Timber.tag(TAG).d("Skipping onResumed refresh (debounced)")
return
}
refreshHomeData() refreshHomeData()
runSideEffects() 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) { suspend fun onRefresh(setRefreshing: (Boolean) -> Unit) {
refreshHomeData(setRefreshing) refreshHomeData(setRefreshing)
runSideEffects() runSideEffects()
lastRefreshAtMs.store(SystemClock.elapsedRealtime())
} }
private suspend fun refreshHomeData( 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() { private suspend fun loadLatestLibraryContent() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() } // Reuse the libraries already loaded by loadLibraries() so we don't refetch
.getOrElse { error -> // the same /Users/{userId}/Views response a second time per refresh.
handleRefreshFailure(error, "Unable to load latest library content") val filteredLibraries = librariesState.value
} val url = serverUrl()
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
}
val latestLibraryContents = filteredLibraries.associate { library -> val latestLibraryContents = filteredLibraries.associate { library ->
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) } val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
.getOrElse { error -> .getOrElse { error ->
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}") handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
} }
library.id to when (library.collectionType) { library.id to when (library.type) {
CollectionType.MOVIES -> latestFromLibrary.map { LibraryKind.MOVIES -> latestFromLibrary.map {
val movie = it.toMovie(serverUrl()) val movie = it.toMovie(url)
Media.MovieMedia(movieId = movie.id) Media.MovieMedia(movieId = movie.id)
} }
CollectionType.TVSHOWS -> latestFromLibrary.map { LibraryKind.SERIES -> latestFromLibrary.map {
when (it.type) { when (it.type) {
BaseItemKind.SERIES -> { BaseItemKind.SERIES -> {
val series = it.toSeries(serverUrl()) val series = it.toSeries(url)
Media.SeriesMedia(seriesId = series.id) Media.SeriesMedia(seriesId = series.id)
} }
BaseItemKind.SEASON -> { BaseItemKind.SEASON -> {
@@ -331,13 +328,12 @@ class InMemoryAppContentRepository @Inject constructor(
Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId) Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId)
} }
BaseItemKind.EPISODE -> { BaseItemKind.EPISODE -> {
val episode = it.toEpisode(serverUrl()) val episode = it.toEpisode(url)
Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId) Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId)
} }
else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}") else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}")
} }
} }
else -> throw UnsupportedOperationException("Unsupported library type: ${library.collectionType}")
} }
} }
latestLibraryContentState.value = latestLibraryContents latestLibraryContentState.value = latestLibraryContents

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.data.jellyfin.playback package hu.bbara.purefin.data.jellyfin.playback
import android.os.SystemClock
import hu.bbara.purefin.core.Offline import hu.bbara.purefin.core.Offline
import hu.bbara.purefin.core.Online import hu.bbara.purefin.core.Online
import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.LocalMediaRepository
@@ -15,6 +16,9 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.concurrent.atomics.AtomicLong
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.fetchAndIncrement
import kotlin.math.roundToLong import kotlin.math.roundToLong
class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor( class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor(
@@ -25,17 +29,44 @@ class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor(
@param:Online private val onlineRepository: LocalMediaRepository, @param:Online private val onlineRepository: LocalMediaRepository,
) : HomeRefreshSideEffect { ) : 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() { override suspend fun run() {
if (!networkMonitor.isOnline.first()) return if (!networkMonitor.isOnline.first()) return
val localPlaybackPositions = buildList { val now = SystemClock.elapsedRealtime()
addAll(offlineRepository.movies.first().values.map { it.toLocalPlaybackPosition() }) if (now - lastRunAtElapsedMs.load() < MIN_INTERVAL_MS) {
addAll(offlineRepository.episodes.first().values.map { it.toLocalPlaybackPosition() }) Timber.tag(TAG).d("Skipping playback position sync (rate-limited)")
return
} }
localPlaybackPositions.forEach { localPlaybackPosition -> val sorted = buildList {
syncPlaybackPosition(localPlaybackPosition) 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) { private suspend fun syncPlaybackPosition(localPlaybackPosition: LocalPlaybackPosition) {
@@ -151,5 +182,11 @@ class SyncPlaybackPositionsHomeRefreshSideEffect @Inject constructor(
private companion object { private companion object {
const val TAG = "PlaybackPositionSync" const val TAG = "PlaybackPositionSync"
const val PROGRESS_TOLERANCE_PERCENT = 0.5 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
} }
} }