fix: Prevent duplicate API calls on app startup

ensureReady() had a race condition where concurrent callers from init
block and ViewModels would all run the loading logic simultaneously.
Added Mutex to ensure only the first caller loads; others await.

Also added a cooldown to refreshHomeData() since LifecycleResumeEffect
fires immediately on first composition, triggering a redundant reload
right after the initial load completes.
This commit is contained in:
2026-02-18 18:50:16 +01:00
parent 9a9cb9c2e7
commit 198e40d1e8

View File

@@ -24,6 +24,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@@ -45,7 +47,9 @@ class InMemoryMediaRepository @Inject constructor(
) : MediaRepository { ) : MediaRepository {
private val ready = CompletableDeferred<Unit>() private val ready = CompletableDeferred<Unit>()
private val readyMutex = Mutex()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var initialLoadTimestamp = 0L
private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Loading) private val _state: MutableStateFlow<MediaRepositoryState> = MutableStateFlow(MediaRepositoryState.Loading)
override val state: StateFlow<MediaRepositoryState> = _state.asStateFlow() override val state: StateFlow<MediaRepositoryState> = _state.asStateFlow()
@@ -85,19 +89,34 @@ class InMemoryMediaRepository @Inject constructor(
} }
override suspend fun ensureReady() { override suspend fun ensureReady() {
if (ready.isCompleted) return if (ready.isCompleted) {
ready.await() // rethrows if completed exceptionally
return
}
try { // Only the first caller runs the loading logic; others wait on the deferred.
loadLibraries() if (readyMutex.tryLock()) {
loadContinueWatching() try {
loadNextUp() if (ready.isCompleted) {
loadLatestLibraryContent() ready.await()
_state.value = MediaRepositoryState.Ready return
ready.complete(Unit) }
} catch (t: Throwable) { loadLibraries()
_state.value = MediaRepositoryState.Error(t) loadContinueWatching()
ready.completeExceptionally(t) loadNextUp()
throw t loadLatestLibraryContent()
_state.value = MediaRepositoryState.Ready
initialLoadTimestamp = System.currentTimeMillis()
ready.complete(Unit)
} catch (t: Throwable) {
_state.value = MediaRepositoryState.Error(t)
ready.completeExceptionally(t)
throw t
} finally {
readyMutex.unlock()
}
} else {
ready.await()
} }
} }
@@ -269,11 +288,21 @@ class InMemoryMediaRepository @Inject constructor(
localDataSource.updateWatchProgress(mediaId, progressPercent, watched) localDataSource.updateWatchProgress(mediaId, progressPercent, watched)
} }
companion object {
private const val REFRESH_MIN_INTERVAL_MS = 30_000L
}
override suspend fun refreshHomeData() { override suspend fun refreshHomeData() {
awaitReady()
// Skip refresh if the initial load (or last refresh) just happened
val elapsed = System.currentTimeMillis() - initialLoadTimestamp
if (elapsed < REFRESH_MIN_INTERVAL_MS) return
loadLibraries() loadLibraries()
loadContinueWatching() loadContinueWatching()
loadNextUp() loadNextUp()
loadLatestLibraryContent() loadLatestLibraryContent()
initialLoadTimestamp = System.currentTimeMillis()
} }
private suspend fun serverUrl(): String { private suspend fun serverUrl(): String {