diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/refresh/HomeRefreshCoordinator.kt b/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/refresh/HomeRefreshCoordinator.kt index 30b44cdd..2c984186 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/refresh/HomeRefreshCoordinator.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/refresh/HomeRefreshCoordinator.kt @@ -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 + } } diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt index 1f3f7ec3..333a21e6 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt @@ -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 diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SyncPlaybackPositionsHomeRefreshSideEffect.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SyncPlaybackPositionsHomeRefreshSideEffect.kt index 4a24e992..25825649 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SyncPlaybackPositionsHomeRefreshSideEffect.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/SyncPlaybackPositionsHomeRefreshSideEffect.kt @@ -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 } }