diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a1dbd68..29c36ac2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -285,6 +285,8 @@ dependencies { ksp(libs.auto.service.ksp) implementation(platform(libs.okhttp.bom)) implementation(libs.okhttp) + implementation(libs.kache) + implementation(libs.kache.file) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 183fb261..2e7ccddd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -186,6 +186,45 @@ sealed interface AppPreference { summarizer = { value -> value?.toString() }, ) + val MaxDaysNextUpOptions = listOf(7, 14, 30, 60, 90, 180, 365) + val MaxDaysNextUp = + AppSliderPreference( + title = R.string.max_days_next_up, + defaultValue = -1, + min = 0, + // Max is "no limit" stored as -1 + max = MaxDaysNextUpOptions.lastIndex + 1L, + interval = 1, + getter = { + MaxDaysNextUpOptions + .indexOf(it.homePagePreferences.maxDaysNextUp) + .takeIf { it in MaxDaysNextUpOptions.indices } + ?.toLong() + ?: MaxDaysNextUpOptions.size.toLong() + }, + setter = { prefs, index -> + prefs.updateHomePagePreferences { + maxDaysNextUp = MaxDaysNextUpOptions.getOrNull(index.toInt()) ?: -1 + } + }, + summarizer = { value -> + if (value != null) { + val v = MaxDaysNextUpOptions.getOrNull(value.toInt()) ?: -1 + if (v == -1) { + WholphinApplication.instance.getString(R.string.no_limit) + } else { + WholphinApplication.instance.resources.getQuantityString( + R.plurals.days, + v, + v.toString(), + ) + } + } else { + null + } + }, + ) + val CombineContinueNext = AppSwitchPreference( title = R.string.combine_continue_next, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index e2182aa3..6adcdca4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -82,6 +82,7 @@ class AppPreferencesSerializer maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt() enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue combineContinueNext = AppPreference.CombineContinueNext.defaultValue + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() }.build() interfacePreferences = InterfacePreferences diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 96130f48..057ec550 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences +import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions @@ -237,4 +238,12 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { + appPreferences.updateData { + it.updateHomePagePreferences { + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 412166bf..96e9efa9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -555,6 +555,7 @@ class HomeSettingsService limit, prefs.enableRewatchingNextUp, false, + prefs.maxDaysNextUp, row.viewOptions.useSeries, ) @@ -579,6 +580,7 @@ class HomeSettingsService limit, prefs.enableRewatchingNextUp, false, + prefs.maxDaysNextUp, row.viewOptions.useSeries, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index c9c989f2..9342f63a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -75,8 +75,11 @@ class LatestNextUpService limit: Int, enableRewatching: Boolean, enableResumable: Boolean, + maxDays: Int, useSeriesForPrimary: Boolean = true, ): List { + val nextUpDateCutoff = + maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } val request = GetNextUpRequest( userId = userId, @@ -87,6 +90,7 @@ class LatestNextUpService enableResumable = enableResumable, enableUserData = true, enableRewatching = enableRewatching, + nextUpDateCutoff = nextUpDateCutoff, ) val nextUp = api.tvShowsApi diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index cc9f5779..4be082a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -4,9 +4,6 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.github.damontecres.wholphin.ui.nav.Destination import dagger.hilt.android.scopes.ActivityRetainedScoped -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -19,7 +16,6 @@ class PlaybackLifecycleObserver private val navigationManager: NavigationManager, private val playerFactory: PlayerFactory, private val themeSongPlayer: ThemeSongPlayer, - private val suggestionsCache: SuggestionsCache, ) : DefaultLifecycleObserver { private var wasPlaying: Boolean? = null @@ -52,6 +48,5 @@ class PlaybackLifecycleObserver override fun onStop(owner: LifecycleOwner) { themeSongPlayer.stop() - CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt index a38c713d..db6cef2c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.util.GetItemsRequestHandler import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf @@ -50,32 +49,31 @@ class SuggestionService .asFlow() .flatMapLatest { user -> val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty) - - cache.cacheVersion - .map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() } - .distinctUntilChanged() - .flatMapLatest { cachedIds -> - if (cachedIds.isNotEmpty()) { - flow { - try { - emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind))) - } catch (e: Exception) { - Timber.e(e, "Failed to fetch items") - emit(SuggestionsResource.Empty) - } - } - } else { - workManager - .getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) - .map { workInfos -> - val isActive = - workInfos.any { - it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED - } - if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty - } + val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty() + if (cachedIds.isNotEmpty()) { + flow { + try { + emit( + SuggestionsResource.Success( + fetchItemsByIds(cachedIds, itemKind), + ), + ) + } catch (e: Exception) { + Timber.e(e, "Failed to fetch items") + emit(SuggestionsResource.Empty) } } + } else { + workManager + .getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) + .map { workInfos -> + val isActive = + workInfos.any { + it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED + } + if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt index f131b13b..5542ea51 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -4,15 +4,13 @@ package com.github.damontecres.wholphin.services import android.content.Context import com.github.damontecres.wholphin.ui.toServerString +import com.mayakapps.kache.InMemoryKache +import com.mayakapps.kache.ObjectKache import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers @@ -38,39 +36,15 @@ class SuggestionsCache constructor( @param:ApplicationContext private val context: Context, ) { + private val cacheDir: File + get() = File(context.cacheDir, "suggestions") private val json = Json { ignoreUnknownKeys = true } - private val _cacheVersion = MutableStateFlow(0L) - val cacheVersion: StateFlow = _cacheVersion.asStateFlow() - - private val memoryCache: MutableMap = - LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true) - - @Volatile - private var diskCacheLoadedUserId: UUID? = null - private val dirtyKeys: MutableSet = mutableSetOf() private val mutex = Mutex() - @OptIn(ExperimentalSerializationApi::class) - private fun writeEntryToDisk( - key: String, - cached: CachedSuggestions, - ) { - runCatching { - val suggestionsDir = cacheDir.apply { mkdirs() } - File(suggestionsDir, "$key.json") - .outputStream() - .use { json.encodeToStream(cached, it) } - }.onFailure { Timber.w(it, "Failed to write evicted cache: $key") } - } - - private fun checkForEviction(newKey: String): Pair? { - if (memoryCache.containsKey(newKey) || memoryCache.size < MAX_MEMORY_CACHE_SIZE) { - return null + private val memoryCache = + InMemoryKache(maxSize = 8) { + creationScope = CoroutineScope(Dispatchers.IO) } - val eldest = memoryCache.entries.firstOrNull() ?: return null - memoryCache.remove(eldest.key) - return if (dirtyKeys.remove(eldest.key)) eldest.key to eldest.value else null - } private fun cacheKey( userId: UUID, @@ -78,63 +52,28 @@ class SuggestionsCache itemKind: BaseItemKind, ) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}" - private val cacheDir: File - get() = File(context.cacheDir, "suggestions") - - @OptIn(ExperimentalSerializationApi::class) - private suspend fun loadFromDisk(userId: UUID) { - if (diskCacheLoadedUserId == userId) return - mutex.withLock { - if (diskCacheLoadedUserId == userId) return@withLock - withContext(Dispatchers.IO) { - val suggestionsDir = cacheDir - if (!suggestionsDir.exists()) { - diskCacheLoadedUserId = userId - return@withContext - } - memoryCache.clear() - suggestionsDir - .listFiles { - it.name.startsWith(userId.toServerString()) - }.orEmpty() - .take(MAX_MEMORY_CACHE_SIZE) - .forEach { file -> - runCatching { - val key = file.nameWithoutExtension - val cached = - file - .inputStream() - .use { json.decodeFromStream(it) } - memoryCache[key] = cached - }.onFailure { Timber.w(it, "Failed to read cache file: ${file.name}") } - } - diskCacheLoadedUserId = userId - } - } - } - @OptIn(ExperimentalSerializationApi::class) suspend fun get( userId: UUID, libraryId: UUID, itemKind: BaseItemKind, ): CachedSuggestions? { - loadFromDisk(userId) val key = cacheKey(userId, libraryId, itemKind) - memoryCache[key]?.let { return it } - return withContext(Dispatchers.IO) { - runCatching { - File(cacheDir, "$key.json") - .takeIf { it.exists() } - ?.inputStream() - ?.use { + return memoryCache.getOrPut(key) { + try { + mutex.withLock { + File(cacheDir, "$key.json").inputStream().use { json.decodeFromStream(it) - }?.also { memoryCache[key] = it } - }.onFailure { Timber.w(it, "Failed to read cache: $key") } - .getOrNull() + } + } + } catch (ex: Exception) { + Timber.e(ex, "Exception reading from disk cache") + null + } } } + @OptIn(ExperimentalSerializationApi::class) suspend fun put( userId: UUID, libraryId: UUID, @@ -142,75 +81,23 @@ class SuggestionsCache ids: List, ) { val key = cacheKey(userId, libraryId, itemKind) - val cached = CachedSuggestions(ids) - val evictedEntry = + val suggestions = CachedSuggestions(ids) + memoryCache.put(key, suggestions) + try { + cacheDir.mkdirs() mutex.withLock { - val evicted = checkForEviction(key) - memoryCache[key] = cached - dirtyKeys.add(key) - _cacheVersion.update { it + 1 } - evicted - } - evictedEntry?.let { (evictedKey, evictedValue) -> - withContext(Dispatchers.IO) { - writeEntryToDisk(evictedKey, evictedValue) - } - } - } - - suspend fun isEmpty(): Boolean = - mutex.withLock { - if (memoryCache.isNotEmpty() || dirtyKeys.isNotEmpty()) { - return@withLock false - } - withContext(Dispatchers.IO) { - val files = cacheDir.listFiles() - files == null || files.isEmpty() - } - } - - @OptIn(ExperimentalSerializationApi::class) - suspend fun save() { - val entriesToSave = - mutex.withLock { - if (dirtyKeys.isEmpty()) return - val entries = - dirtyKeys.mapNotNull { key -> - memoryCache[key]?.let { key to it } - } - dirtyKeys.clear() - entries - } - - withContext(Dispatchers.IO) { - val suggestionsDir = - cacheDir.apply { - if (!mkdirs() && !exists()) Timber.w("Failed to create suggestions cache directory") + File(cacheDir, "$key.json").outputStream().use { + json.encodeToStream(suggestions, it) } - entriesToSave.forEach { (key, value) -> - runCatching { - File(suggestionsDir, "$key.json") - .outputStream() - .use { json.encodeToStream(value, it) } - }.onFailure { Timber.w(it, "Failed to write cache: $key") } } + } catch (ex: Exception) { + Timber.e(ex, "Exception writing to disk cache") } } - - suspend fun clear() { - mutex.withLock { - memoryCache.clear() - dirtyKeys.clear() - _cacheVersion.update { it + 1 } - diskCacheLoadedUserId = null - } - withContext(Dispatchers.IO) { - runCatching { cacheDir.deleteRecursively() } - .onFailure { Timber.w(it, "Failed to clear suggestions cache") } - } - } - - companion object { - private const val MAX_MEMORY_CACHE_SIZE = 8 - } } + +fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L + +fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty() + +suspend fun ObjectKache.containsKey(key: T): Boolean = get(key) != null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt index 42d59dae..edca582b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -80,13 +80,7 @@ class SuggestionsSchedulerService BackoffPolicy.EXPONENTIAL, 15.minutes.toJavaDuration(), ).setInputData(inputData) - - if (cache.isEmpty()) { - Timber.i("Suggestions cache empty, scheduling periodic fetch with 30s delay") - periodicWorkRequestBuilder.setInitialDelay(30.seconds.toJavaDuration()) - } else { - Timber.i("Scheduling periodic SuggestionsWorker") - } + .setInitialDelay(60.seconds.toJavaDuration()) workManager.enqueueUniquePeriodicWork( uniqueWorkName = SuggestionsWorker.WORK_NAME, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index ff883ff3..eab87f20 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -31,6 +32,7 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.UUID +import kotlin.coroutines.CoroutineContext private val BaseItemDto.relevantId: UUID get() = seriesId ?: id @@ -79,6 +81,7 @@ class SuggestionsWorker } val results = supervisorScope { + val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions") views .mapNotNull { view -> val itemKind = @@ -87,7 +90,14 @@ class SuggestionsWorker async(Dispatchers.IO) { runCatching { Timber.v("Fetching suggestions for view %s", view.id) - val suggestions = fetchSuggestions(view.id, userId, itemKind, itemsPerRow) + val suggestions = + fetchSuggestions( + context, + view.id, + userId, + itemKind, + itemsPerRow, + ) ensureActive() cache.put( userId, @@ -107,7 +117,6 @@ class SuggestionsWorker } val successCount = results.count { it.isSuccess } val failureCount = results.count { it.isFailure } - cache.save() if (failureCount > 0 && successCount == 0) { Timber.w("All attempts failed ($failureCount views), scheduling retry") return Result.retry() @@ -124,6 +133,7 @@ class SuggestionsWorker } private suspend fun fetchSuggestions( + coroutineContext: CoroutineContext, parentId: UUID, userId: UUID, itemKind: BaseItemKind, @@ -138,7 +148,7 @@ class SuggestionsWorker val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1) val historyDeferred = - async(Dispatchers.IO) { + async(coroutineContext) { fetchItems( parentId = parentId, userId = userId, @@ -160,7 +170,7 @@ class SuggestionsWorker val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId } val contextualDeferred = - async(Dispatchers.IO) { + async(coroutineContext) { if (allGenreIds.isEmpty()) { emptyList() } else { @@ -178,7 +188,7 @@ class SuggestionsWorker } val randomDeferred = - async(Dispatchers.IO) { + async(coroutineContext) { fetchItems( parentId = parentId, userId = userId, @@ -190,7 +200,7 @@ class SuggestionsWorker } val freshDeferred = - async(Dispatchers.IO) { + async(coroutineContext) { fetchItems( parentId = parentId, userId = userId, @@ -201,18 +211,19 @@ class SuggestionsWorker limit = freshLimit, ) } + withContext(Dispatchers.Default) { + val contextual = contextualDeferred.await() + val random = randomDeferred.await() + val fresh = freshDeferred.await() - val contextual = contextualDeferred.await() - val random = randomDeferred.await() - val fresh = freshDeferred.await() - - (contextual + fresh + random) - .asSequence() - .distinctBy { it.id } - .filterNot { excludeIds.contains(it.relevantId) } - .toList() - .shuffled() - .take(itemsPerRow) + (contextual + fresh + random) + .asSequence() + .distinctBy { it.id } + .filterNot { excludeIds.contains(it.relevantId) } + .toList() + .shuffled() + .take(itemsPerRow) + } } private suspend fun fetchItems( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt index ab0e04bc..297cbd64 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -21,6 +21,7 @@ import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @ActivityScoped @@ -60,7 +61,8 @@ class TvProviderSchedulerService TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), ), - ).build(), + ).setInitialDelay(60.seconds.toJavaDuration()) + .build(), ).await() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 1a4dd99e..03ea0478 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -80,6 +80,7 @@ class TvProviderWorker getPotentialItems( userId, prefs.homePagePreferences.enableRewatchingNextUp, + prefs.homePagePreferences.maxDaysNextUp, ) val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } @@ -143,12 +144,13 @@ class TvProviderWorker private suspend fun getPotentialItems( userId: UUID, enableRewatching: Boolean, + maxDaysNextUp: Int, ): List { val resumeItems = latestNextUpService.getResume(userId, 10, true) val seriesIds = resumeItems.mapNotNull { it.data.seriesId } val nextUpItems = latestNextUpService - .getNextUp(userId, 10, enableRewatching, false) + .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp) .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } return latestNextUpService.buildCombined(resumeItems, nextUpItems) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt index 1ad803ff..e4b61881 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -81,6 +81,18 @@ fun HomeSettingsGlobal( modifier = Modifier, ) } + item { + ComposablePreference( + preference = AppPreference.MaxDaysNextUp, + value = AppPreference.MaxDaysNextUp.getter.invoke(preferences), + onValueChange = { + val newPrefs = AppPreference.MaxDaysNextUp.setter.invoke(preferences, it) + onPreferenceChange.invoke(newPrefs) + }, + onNavigate = {}, + modifier = Modifier, + ) + } item { HorizontalDivider() } item { HomeSettingsListItem( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index ff7643d8..0e3d5a43 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSerializable @@ -231,6 +232,7 @@ fun ApplicationContent( ) } } + val navDrawerListState = rememberLazyListState() NavDisplay( backStack = navigationManager.backStack, onBack = { navigationManager.goBack() }, @@ -257,6 +259,7 @@ fun ApplicationContent( user = user, server = server, drawerState = drawerState, + navDrawerListState = navDrawerListState, onClearBackdrop = viewModel::clearBackdrop, modifier = Modifier.fillMaxSize(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 0b9c8135..6eac3206 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -18,8 +18,8 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home @@ -41,7 +41,6 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView @@ -88,12 +87,10 @@ import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi @@ -272,6 +269,7 @@ fun NavDrawer( user: JellyfinUser, server: JellyfinServer, drawerState: DrawerState, + navDrawerListState: LazyListState, onClearBackdrop: () -> Unit, modifier: Modifier = Modifier, viewModel: NavDrawerViewModel = @@ -320,8 +318,6 @@ fun NavDrawer( visibilityThreshold = IntOffset.VisibilityThreshold, ), ) - val config = LocalConfiguration.current - val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } } ModalNavigationDrawer( modifier = modifier, @@ -329,28 +325,8 @@ fun NavDrawer( drawerContent = { drawerValue -> val isOpen = drawerValue.isOpen val spacedBy = 4.dp - val listState = rememberLazyListState() val searchFocusRequester = remember { FocusRequester() } - suspend fun scrollToSelected() { - val target = selectedIndex + 2 - try { - if (target !in - listState.firstVisibleItemIndex..%d hour %d hours + + %s days + %s day + %s days + %d items %d item @@ -485,6 +490,8 @@ Slideshow duration Play videos during slideshow Send media info log to server + No limit + Max days in Next Up Add row Genres in %1$s diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt index 2f858ab1..e1663543 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -12,7 +12,6 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher @@ -86,7 +85,6 @@ class SuggestionServiceTest { runTest { val currentUser = MutableLiveData(null) every { mockServerRepository.currentUser } returns currentUser - every { mockCache.cacheVersion } returns MutableStateFlow(0L) every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) val service = createService() @@ -104,7 +102,6 @@ class SuggestionServiceTest { val currentUser = MutableLiveData(mockUser(userId)) every { mockServerRepository.currentUser } returns currentUser - every { mockCache.cacheVersion } returns MutableStateFlow(0L) coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(listOf(mockWorkInfo(state))) @@ -125,7 +122,6 @@ class SuggestionServiceTest { val currentUser = MutableLiveData(mockUser(userId)) every { mockServerRepository.currentUser } returns currentUser - every { mockCache.cacheVersion } returns MutableStateFlow(0L) coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(listOf(mockWorkInfo(state))) @@ -145,7 +141,6 @@ class SuggestionServiceTest { val currentUser = MutableLiveData(mockUser(userId)) every { mockServerRepository.currentUser } returns currentUser - every { mockCache.cacheVersion } returns MutableStateFlow(0L) coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) @@ -164,7 +159,6 @@ class SuggestionServiceTest { val currentUser = MutableLiveData(mockUser(userId)) every { mockServerRepository.currentUser } returns currentUser - every { mockCache.cacheVersion } returns MutableStateFlow(0L) every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null @@ -199,7 +193,6 @@ class SuggestionServiceTest { val currentUser = MutableLiveData(mockUser(userId)) every { mockServerRepository.currentUser } returns currentUser - every { mockCache.cacheVersion } returns MutableStateFlow(0L) val cachedId = UUID.randomUUID() coEvery { @@ -237,7 +230,6 @@ class SuggestionServiceTest { val currentUser = MutableLiveData(mockUser(userId)) every { mockServerRepository.currentUser } returns currentUser - every { mockCache.cacheVersion } returns MutableStateFlow(0L) val cachedId = UUID.randomUUID() coEvery { diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt index 709509c6..82e3f2c8 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.content.Context +import com.mayakapps.kache.ObjectKache import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -28,11 +29,11 @@ class SuggestionsCacheTest { return SuggestionsCache(mockContext) } - private fun memoryCacheOf(cache: SuggestionsCache): MutableMap { + private fun memoryCacheOf(cache: SuggestionsCache): ObjectKache { val field = SuggestionsCache::class.java.getDeclaredField("memoryCache") field.isAccessible = true @Suppress("UNCHECKED_CAST") - return field.get(cache) as MutableMap + return field.get(cache) as ObjectKache } @Test @@ -57,7 +58,6 @@ class SuggestionsCacheTest { val libId = UUID.randomUUID() cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList()) - cache1.save() // Create a fresh instance which won't have the memory entry val cache2 = testCacheWithTempDir() @@ -192,7 +192,6 @@ class SuggestionsCacheTest { val cache1 = testCacheWithTempDir() cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1) cache1.put(userId, lib2, BaseItemKind.SERIES, ids2) - cache1.save() // Read with fresh cache instance (empty memory cache, reads from disk) val cache2 = testCacheWithTempDir() diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt index 088b4678..7da1d63c 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser -import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -30,7 +29,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import java.util.UUID -import java.util.concurrent.TimeUnit @OptIn(ExperimentalCoroutinesApi::class) class SuggestionsSchedulerServiceTest { @@ -65,7 +63,6 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_when_user_present() = runTest { - coEvery { mockCache.isEmpty() } returns false createService() currentLiveData.value = CurrentUser( @@ -79,7 +76,6 @@ class SuggestionsSchedulerServiceTest { @Test fun cancels_work_when_user_null() = runTest { - coEvery { mockCache.isEmpty() } returns false createService() currentLiveData.value = CurrentUser( @@ -95,7 +91,6 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_with_delay_when_cache_empty() = runTest { - coEvery { mockCache.isEmpty() } returns true val workRequestSlot = slot() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -114,13 +109,12 @@ class SuggestionsSchedulerServiceTest { advanceUntilIdle() verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } - assertEquals(30000L, workRequestSlot.captured.workSpec.initialDelay) + assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) } @Test - fun schedules_periodic_work_without_delay_when_cache_not_empty() = + fun schedules_periodic_work_with_delay_when_cache_not_empty() = runTest { - coEvery { mockCache.isEmpty() } returns false val workRequestSlot = slot() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -139,6 +133,6 @@ class SuggestionsSchedulerServiceTest { advanceUntilIdle() verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } - assertEquals(0L, workRequestSlot.captured.workSpec.initialDelay) + assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt index ebef9aec..7d633d87 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -78,11 +78,6 @@ class SuggestionsWorkerTest { every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 } } - private fun mockQueryResult(items: List = emptyList()) = - mockk>(relaxed = true) { - every { content } returns mockk { every { this@mockk.items } returns items } - } - @Test fun returns_failure_on_invalid_input() = runTest { @@ -137,7 +132,6 @@ class SuggestionsWorkerTest { assertEquals(ListenableWorker.Result.success(), result) coVerify { mockCache.put(testUserId, viewId, itemKind, any()) } - coVerify { mockCache.save() } } } @@ -231,3 +225,8 @@ class SuggestionsWorkerTest { assertEquals(ListenableWorker.Result.retry(), result) } } + +fun mockQueryResult(items: List = emptyList()) = + mockk>(relaxed = true) { + every { content } returns mockk { every { this@mockk.items } returns items } + } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt new file mode 100644 index 00000000..0e9bb92e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt @@ -0,0 +1,136 @@ +package com.github.damontecres.wholphin.test + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.updateHomePagePreferences +import com.github.damontecres.wholphin.services.DatePlayedService +import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.mockQueryResult +import io.mockk.CapturingSlot +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate + +class NextUpTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private val mockTvShowsApi = mockk() + private val mockApi = mockk(relaxed = true) + private val mockContext = mockk() + private val mockDatePlayedService = mockk() + + private val latestNextUpService = + LatestNextUpService(mockContext, mockApi, mockDatePlayedService) + + @Before + fun setUp() { + every { mockApi.tvShowsApi } returns mockTvShowsApi + } + + @Test + fun `Test max 30 days in next up`() = + runTest { + val maxDays = 30 + val nextUpSlot = CapturingSlot() + coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult() + latestNextUpService.getNextUp( + userId = UUID.randomUUID(), + limit = 10, + enableRewatching = true, + enableResumable = true, + maxDays = maxDays, + ) + Assert.assertEquals(10, nextUpSlot.captured.limit) + val expected = LocalDate.now().minusDays(maxDays.toLong()) + Assert.assertEquals(expected, nextUpSlot.captured.nextUpDateCutoff?.toLocalDate()) + } + + @Test + fun `Test no limit in next up`() = + runTest { + val nextUpSlot = CapturingSlot() + coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult() + latestNextUpService.getNextUp( + userId = UUID.randomUUID(), + limit = 10, + enableRewatching = true, + enableResumable = true, + maxDays = -1, + ) + Assert.assertEquals(10, nextUpSlot.captured.limit) + Assert.assertNull(nextUpSlot.captured.nextUpDateCutoff) + } + + @Test + fun `Test storing preference`() { + AppPreference.MaxDaysNextUp.setter.invoke(AppPreferences.getDefaultInstance(), 0).let { + Assert.assertEquals(7, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke( + AppPreferences.getDefaultInstance(), + AppPreference.MaxDaysNextUpOptions.lastIndex.toLong(), + ).let { + Assert.assertEquals(365, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke(AppPreferences.getDefaultInstance(), 3) + .let { + Assert.assertEquals(60, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke( + AppPreferences.getDefaultInstance(), + AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, + ).let { + Assert.assertEquals(-1, it.homePagePreferences.maxDaysNextUp) + } + } + + @Test + fun `Test getting preference`() { + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = 7 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(0, result) + } + + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = 60 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(3, result) + } + + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = -1 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, result) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2a32580..32a80878 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,9 +8,10 @@ desugar_jdk_libs = "2.1.5" hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" +kache = "2.1.1" kotlin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" -ksp = "2.3.0" +ksp = "2.3.5" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2026.01.01" @@ -25,7 +26,7 @@ tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" -androidx-media3 = "1.9.1" +androidx-media3 = "1.9.2" coil = "3.3.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.0" @@ -80,6 +81,8 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } +kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..dc6fbf78 --- /dev/null +++ b/renovate.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "packageRules": [ + { + "groupName": "Dependencies", + "matchDepTypes": ["dependencies"], + "separateMultipleMajor": true + }, + { + "groupName": "Github Actions", + "matchManagers": ["github-actions"] + }, + { + "groupName": "Androidx Media3", + "matchPackageNames": [ + "androidx.media3:**" + ] + }, + { + "groupName": "Jellyfin SDK", + "matchPackageNames": [ + "org.jellyfin.sdk:**" + ] + }, + { + "groupName": "AGP", + "matchPackageNames": [ + "com.android.application:**" + ] + }, + { + "groupName": "Kotlin", + "matchPackageNames": [ + "org.jetbrains.kotlin**" + ] + } + ] +}