mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user