Fix crash when go backing too quickly (#309)

Fixes #306 

Also fixes network-on-main and potential concurrency issues
This commit is contained in:
damontecres 2025-11-23 17:19:01 -05:00 committed by GitHub
parent 78e7eb421f
commit 5fbe12e6cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 54 additions and 42 deletions

View file

@ -5,12 +5,17 @@ import androidx.appcompat.app.AppCompatActivity
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import com.google.common.cache.CacheLoader import com.google.common.cache.CacheLoader
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -28,46 +33,47 @@ class DatePlayedService
@Inject @Inject
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
@param:IoCoroutineScope private val scope: CoroutineScope,
) { ) {
private val datePlayedCache = private val datePlayedCache =
CacheBuilder CacheBuilder
.newBuilder() .newBuilder()
.maximumSize(AppPreference.HomePageItems.max) .maximumSize(AppPreference.HomePageItems.max)
.expireAfterWrite(2, TimeUnit.HOURS) .expireAfterWrite(2, TimeUnit.HOURS)
.build<SeriesItemId, LocalDateTime?>( .build<SeriesItemId, Deferred<LocalDateTime>>(
object : object : CacheLoader<SeriesItemId, Deferred<LocalDateTime>>() {
CacheLoader<SeriesItemId, LocalDateTime>() { override fun load(key: SeriesItemId): Deferred<LocalDateTime> = getLastPlayed(key)
override fun load(key: SeriesItemId): LocalDateTime = getLastPlayed(key)
}, },
) )
private fun getLastPlayed(key: SeriesItemId): LocalDateTime { private fun getLastPlayed(key: SeriesItemId): Deferred<LocalDateTime> {
val request = val request =
GetEpisodesRequest( GetEpisodesRequest(
seriesId = key.seriesId, seriesId = key.seriesId,
adjacentTo = key.itemId, adjacentTo = key.itemId,
limit = 1, limit = 1,
) )
return try { return scope.async(Dispatchers.IO) {
try {
val result = val result =
runBlocking {
GetEpisodesRequestHandler GetEpisodesRequestHandler
.execute( .execute(
api, api,
request, request,
).content.items ).content.items
}
result.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN result.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN
} catch (ex: InvalidStatusException) { } catch (ex: InvalidStatusException) {
Timber.w("Error fetching %s: %s", key, ex.localizedMessage) Timber.w("Error fetching %s: %s", key, ex.localizedMessage)
LocalDateTime.MIN LocalDateTime.MIN
} }
} }
}
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? { suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
withContext(Dispatchers.IO) {
val seriesId = item.data.seriesId val seriesId = item.data.seriesId
return if (seriesId != null) { return@withContext if (seriesId != null) {
datePlayedCache.get(SeriesItemId(seriesId, item.id)) datePlayedCache.get(SeriesItemId(seriesId, item.id)).await()
} else { } else {
null null
} }

View file

@ -42,7 +42,11 @@ class NavigationManager
* Go to the previous page * Go to the previous page
*/ */
fun goBack() { fun goBack() {
synchronized(this) {
if (backStack.size > 1) {
backStack.removeLastOrNull() backStack.removeLastOrNull()
}
}
log() log()
} }

View file

@ -264,24 +264,26 @@ class HomeViewModel
private suspend fun buildCombined( private suspend fun buildCombined(
resume: List<BaseItem>, resume: List<BaseItem>,
nextUp: List<BaseItem>, nextUp: List<BaseItem>,
): List<BaseItem> { ): List<BaseItem> =
withContext(Dispatchers.IO) {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val semaphore = Semaphore(3) val semaphore = Semaphore(3)
val deferred = val deferred =
nextUp nextUp
.filter { it.data.seriesId != null } .filter { it.data.seriesId != null }
.map { item -> .map { item ->
semaphore.withPermit { viewModelScope.async(Dispatchers.IO) {
viewModelScope.async {
try { try {
semaphore.withPermit {
datePlayedService.getLastPlayed(item) datePlayedService.getLastPlayed(item)
}
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error fetching %s", item.id) Timber.e(ex, "Error fetching %s", item.id)
null null
} }
} }
} }
}
val nextUpLastPlayed = deferred.awaitAll() val nextUpLastPlayed = deferred.awaitAll()
val timestamps = mutableMapOf<UUID, LocalDateTime?>() val timestamps = mutableMapOf<UUID, LocalDateTime?>()
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
@ -289,7 +291,7 @@ class HomeViewModel
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
val duration = (System.currentTimeMillis() - start).milliseconds val duration = (System.currentTimeMillis() - start).milliseconds
Timber.v("buildCombined took %s", duration) Timber.v("buildCombined took %s", duration)
return result return@withContext result
} }
fun setWatched( fun setWatched(