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

View file

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

View file

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