mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Fix: optimize suggestions performance (#849)
## Description - Aims to further optimize performance for the suggestions fetching ### Related issues Fixes #833 ### Testing Android Emulator NVIDIA Shield Pro 2019 ## Screenshots N/A ## AI or LLM usage AI being used to assist with updating/creating unit tests
This commit is contained in:
parent
fd9063fbad
commit
0b73b4cf64
4 changed files with 79 additions and 8 deletions
|
|
@ -8,6 +8,7 @@ import androidx.work.Constraints
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
|
@ -17,9 +18,11 @@ import dagger.hilt.android.scopes.ActivityScoped
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
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.random.Random
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -31,7 +34,6 @@ class SuggestionsSchedulerService
|
||||||
constructor(
|
constructor(
|
||||||
@param:ActivityContext private val context: Context,
|
@param:ActivityContext private val context: Context,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val cache: SuggestionsCache,
|
|
||||||
private val workManager: WorkManager,
|
private val workManager: WorkManager,
|
||||||
) {
|
) {
|
||||||
private val activity =
|
private val activity =
|
||||||
|
|
@ -42,6 +44,7 @@ class SuggestionsSchedulerService
|
||||||
|
|
||||||
// Exposed for testing
|
// Exposed for testing
|
||||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
serverRepository.current.observe(activity) { user ->
|
serverRepository.current.observe(activity) { user ->
|
||||||
|
|
@ -60,6 +63,28 @@ class SuggestionsSchedulerService
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
serverId: UUID,
|
serverId: UUID,
|
||||||
) {
|
) {
|
||||||
|
val workInfos =
|
||||||
|
withContext(dispatcher) {
|
||||||
|
workManager
|
||||||
|
.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME)
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
val activeWork =
|
||||||
|
workInfos.firstOrNull {
|
||||||
|
!it.state.isFinished
|
||||||
|
}
|
||||||
|
val scheduledUserId =
|
||||||
|
activeWork
|
||||||
|
?.tags
|
||||||
|
?.firstOrNull { it.startsWith("user:") }
|
||||||
|
?.removePrefix("user:")
|
||||||
|
|
||||||
|
val isAlreadyScheduledForUser = scheduledUserId == userId.toString()
|
||||||
|
if (isAlreadyScheduledForUser) {
|
||||||
|
Timber.d("SuggestionsWorker already scheduled for user %s", userId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val constraints =
|
val constraints =
|
||||||
Constraints
|
Constraints
|
||||||
.Builder()
|
.Builder()
|
||||||
|
|
@ -75,12 +100,18 @@ class SuggestionsSchedulerService
|
||||||
val periodicWorkRequestBuilder =
|
val periodicWorkRequestBuilder =
|
||||||
PeriodicWorkRequestBuilder<SuggestionsWorker>(
|
PeriodicWorkRequestBuilder<SuggestionsWorker>(
|
||||||
repeatInterval = 12.hours.toJavaDuration(),
|
repeatInterval = 12.hours.toJavaDuration(),
|
||||||
|
flexTimeInterval = 1.hours.toJavaDuration(),
|
||||||
).setConstraints(constraints)
|
).setConstraints(constraints)
|
||||||
.setBackoffCriteria(
|
.setBackoffCriteria(
|
||||||
BackoffPolicy.EXPONENTIAL,
|
BackoffPolicy.EXPONENTIAL,
|
||||||
15.minutes.toJavaDuration(),
|
15.minutes.toJavaDuration(),
|
||||||
).setInputData(inputData)
|
).setInputData(inputData)
|
||||||
.setInitialDelay(60.seconds.toJavaDuration())
|
.addTag("user:$userId")
|
||||||
|
|
||||||
|
val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L)
|
||||||
|
periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration())
|
||||||
|
|
||||||
|
Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay")
|
||||||
|
|
||||||
workManager.enqueueUniquePeriodicWork(
|
workManager.enqueueUniquePeriodicWork(
|
||||||
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,17 @@ class SuggestionsWorker
|
||||||
itemsPerRow,
|
itemsPerRow,
|
||||||
)
|
)
|
||||||
ensureActive()
|
ensureActive()
|
||||||
|
val newIds = suggestions.map { it.id }
|
||||||
|
val cachedIds = cache.get(userId, view.id, itemKind)?.ids
|
||||||
|
if (cachedIds == newIds) {
|
||||||
|
Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id)
|
||||||
|
return@runCatching
|
||||||
|
}
|
||||||
cache.put(
|
cache.put(
|
||||||
userId,
|
userId,
|
||||||
view.id,
|
view.id,
|
||||||
itemKind,
|
itemKind,
|
||||||
suggestions.map { it.id },
|
newIds,
|
||||||
)
|
)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Timber.e(
|
Timber.e(
|
||||||
|
|
@ -242,7 +248,7 @@ class SuggestionsWorker
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields,
|
fields = extraFields,
|
||||||
includeItemTypes = listOf(itemKind),
|
includeItemTypes = listOf(itemKind),
|
||||||
genreIds = genreIds,
|
genreIds = genreIds,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
|
|
@ -252,7 +258,7 @@ class SuggestionsWorker
|
||||||
sortOrder = sortOrder?.let { listOf(it) },
|
sortOrder = sortOrder?.let { listOf(it) },
|
||||||
limit = limit,
|
limit = limit,
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 0,
|
||||||
)
|
)
|
||||||
return GetItemsRequestHandler
|
return GetItemsRequestHandler
|
||||||
.execute(api, request)
|
.execute(api, request)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
|
@ -37,7 +38,6 @@ class SuggestionsSchedulerServiceTest {
|
||||||
private val currentLiveData = MutableLiveData<CurrentUser?>()
|
private val currentLiveData = MutableLiveData<CurrentUser?>()
|
||||||
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
||||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||||
private val mockCache = mockk<SuggestionsCache>(relaxed = true)
|
|
||||||
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||||
private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true))
|
private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true))
|
||||||
|
|
||||||
|
|
@ -56,13 +56,23 @@ class SuggestionsSchedulerServiceTest {
|
||||||
SuggestionsSchedulerService(
|
SuggestionsSchedulerService(
|
||||||
context = mockActivity,
|
context = mockActivity,
|
||||||
serverRepository = mockServerRepository,
|
serverRepository = mockServerRepository,
|
||||||
cache = mockCache,
|
|
||||||
workManager = mockWorkManager,
|
workManager = mockWorkManager,
|
||||||
).also { it.dispatcher = testDispatcher }
|
).also {
|
||||||
|
it.dispatcher = testDispatcher
|
||||||
|
it.initialDelaySecondsProvider = { 60L }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mockWorkInfos(infos: List<androidx.work.WorkInfo>) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val future = mockk<ListenableFuture<List<androidx.work.WorkInfo>>>()
|
||||||
|
every { future.get() } returns infos
|
||||||
|
every { mockWorkManager.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) } returns future
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun schedules_periodic_work_when_user_present() =
|
fun schedules_periodic_work_when_user_present() =
|
||||||
runTest {
|
runTest {
|
||||||
|
mockWorkInfos(emptyList())
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentLiveData.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
|
|
@ -76,6 +86,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun cancels_work_when_user_null() =
|
fun cancels_work_when_user_null() =
|
||||||
runTest {
|
runTest {
|
||||||
|
mockWorkInfos(emptyList())
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentLiveData.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
|
|
@ -91,6 +102,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun schedules_periodic_work_with_delay_when_cache_empty() =
|
fun schedules_periodic_work_with_delay_when_cache_empty() =
|
||||||
runTest {
|
runTest {
|
||||||
|
mockWorkInfos(emptyList())
|
||||||
val workRequestSlot = slot<PeriodicWorkRequest>()
|
val workRequestSlot = slot<PeriodicWorkRequest>()
|
||||||
every {
|
every {
|
||||||
mockWorkManager.enqueueUniquePeriodicWork(
|
mockWorkManager.enqueueUniquePeriodicWork(
|
||||||
|
|
@ -115,6 +127,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun schedules_periodic_work_with_delay_when_cache_not_empty() =
|
fun schedules_periodic_work_with_delay_when_cache_not_empty() =
|
||||||
runTest {
|
runTest {
|
||||||
|
mockWorkInfos(emptyList())
|
||||||
val workRequestSlot = slot<PeriodicWorkRequest>()
|
val workRequestSlot = slot<PeriodicWorkRequest>()
|
||||||
every {
|
every {
|
||||||
mockWorkManager.enqueueUniquePeriodicWork(
|
mockWorkManager.enqueueUniquePeriodicWork(
|
||||||
|
|
@ -135,4 +148,24 @@ class SuggestionsSchedulerServiceTest {
|
||||||
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
||||||
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
|
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun does_not_schedule_if_already_scheduled_for_same_user() =
|
||||||
|
runTest {
|
||||||
|
val userId = UUID.randomUUID()
|
||||||
|
val workInfo = mockk<androidx.work.WorkInfo>()
|
||||||
|
every { workInfo.state } returns androidx.work.WorkInfo.State.ENQUEUED
|
||||||
|
every { workInfo.tags } returns setOf("user:$userId")
|
||||||
|
mockWorkInfos(listOf(workInfo))
|
||||||
|
|
||||||
|
createService()
|
||||||
|
currentLiveData.value =
|
||||||
|
CurrentUser(
|
||||||
|
user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||||
|
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify(exactly = 0) { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class SuggestionsWorkerTest {
|
||||||
every { mockApi.userViewsApi } returns mockUserViewsApi
|
every { mockApi.userViewsApi } returns mockUserViewsApi
|
||||||
every { mockApi.baseUrl } returns "http://localhost"
|
every { mockApi.baseUrl } returns "http://localhost"
|
||||||
every { mockApi.accessToken } returns "test-token"
|
every { mockApi.accessToken } returns "test-token"
|
||||||
|
coEvery { mockCache.get(any(), any(), any()) } returns null
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue