mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +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
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user