Merge branch 'main' into develop/customize-home

This commit is contained in:
Damontecres 2026-02-09 18:01:54 -05:00
commit 9fa169a712
No known key found for this signature in database
26 changed files with 368 additions and 264 deletions

View file

@ -285,6 +285,8 @@ dependencies {
ksp(libs.auto.service.ksp) ksp(libs.auto.service.ksp)
implementation(platform(libs.okhttp.bom)) implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.kache)
implementation(libs.kache.file)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -186,6 +186,45 @@ sealed interface AppPreference<Pref, T> {
summarizer = { value -> value?.toString() }, summarizer = { value -> value?.toString() },
) )
val MaxDaysNextUpOptions = listOf(7, 14, 30, 60, 90, 180, 365)
val MaxDaysNextUp =
AppSliderPreference<AppPreferences>(
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 = val CombineContinueNext =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.combine_continue_next, title = R.string.combine_continue_next,

View file

@ -82,6 +82,7 @@ class AppPreferencesSerializer
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt() maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
combineContinueNext = AppPreference.CombineContinueNext.defaultValue combineContinueNext = AppPreference.CombineContinueNext.defaultValue
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
}.build() }.build()
interfacePreferences = interfacePreferences =
InterfacePreferences InterfacePreferences

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences 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.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions 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()
}
}
}
} }

View file

@ -555,6 +555,7 @@ class HomeSettingsService
limit, limit,
prefs.enableRewatchingNextUp, prefs.enableRewatchingNextUp,
false, false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries, row.viewOptions.useSeries,
) )
@ -579,6 +580,7 @@ class HomeSettingsService
limit, limit,
prefs.enableRewatchingNextUp, prefs.enableRewatchingNextUp,
false, false,
prefs.maxDaysNextUp,
row.viewOptions.useSeries, row.viewOptions.useSeries,
) )

View file

@ -75,8 +75,11 @@ class LatestNextUpService
limit: Int, limit: Int,
enableRewatching: Boolean, enableRewatching: Boolean,
enableResumable: Boolean, enableResumable: Boolean,
maxDays: Int,
useSeriesForPrimary: Boolean = true, useSeriesForPrimary: Boolean = true,
): List<BaseItem> { ): List<BaseItem> {
val nextUpDateCutoff =
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
val request = val request =
GetNextUpRequest( GetNextUpRequest(
userId = userId, userId = userId,
@ -87,6 +90,7 @@ class LatestNextUpService
enableResumable = enableResumable, enableResumable = enableResumable,
enableUserData = true, enableUserData = true,
enableRewatching = enableRewatching, enableRewatching = enableRewatching,
nextUpDateCutoff = nextUpDateCutoff,
) )
val nextUp = val nextUp =
api.tvShowsApi api.tvShowsApi

View file

@ -4,9 +4,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -19,7 +16,6 @@ class PlaybackLifecycleObserver
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val suggestionsCache: SuggestionsCache,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
private var wasPlaying: Boolean? = null private var wasPlaying: Boolean? = null
@ -52,6 +48,5 @@ class PlaybackLifecycleObserver
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
themeSongPlayer.stop() themeSongPlayer.stop()
CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() }
} }
} }

View file

@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -50,32 +49,31 @@ class SuggestionService
.asFlow() .asFlow()
.flatMapLatest { user -> .flatMapLatest { user ->
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty) val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty()
cache.cacheVersion if (cachedIds.isNotEmpty()) {
.map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() } flow {
.distinctUntilChanged() try {
.flatMapLatest { cachedIds -> emit(
if (cachedIds.isNotEmpty()) { SuggestionsResource.Success(
flow { fetchItemsByIds(cachedIds, itemKind),
try { ),
emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind))) )
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to fetch items") Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty) 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
}
} }
} }
} 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
}
}
} }
} }

View file

@ -4,15 +4,13 @@ package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.mayakapps.kache.InMemoryKache
import com.mayakapps.kache.ObjectKache
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
@ -38,39 +36,15 @@ class SuggestionsCache
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) { ) {
private val cacheDir: File
get() = File(context.cacheDir, "suggestions")
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val _cacheVersion = MutableStateFlow(0L)
val cacheVersion: StateFlow<Long> = _cacheVersion.asStateFlow()
private val memoryCache: MutableMap<String, CachedSuggestions> =
LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true)
@Volatile
private var diskCacheLoadedUserId: UUID? = null
private val dirtyKeys: MutableSet<String> = mutableSetOf()
private val mutex = Mutex() private val mutex = Mutex()
@OptIn(ExperimentalSerializationApi::class) private val memoryCache =
private fun writeEntryToDisk( InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
key: String, creationScope = CoroutineScope(Dispatchers.IO)
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<String, CachedSuggestions>? {
if (memoryCache.containsKey(newKey) || memoryCache.size < MAX_MEMORY_CACHE_SIZE) {
return null
} }
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( private fun cacheKey(
userId: UUID, userId: UUID,
@ -78,63 +52,28 @@ class SuggestionsCache
itemKind: BaseItemKind, itemKind: BaseItemKind,
) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}" ) = "${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<CachedSuggestions>(it) }
memoryCache[key] = cached
}.onFailure { Timber.w(it, "Failed to read cache file: ${file.name}") }
}
diskCacheLoadedUserId = userId
}
}
}
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun get( suspend fun get(
userId: UUID, userId: UUID,
libraryId: UUID, libraryId: UUID,
itemKind: BaseItemKind, itemKind: BaseItemKind,
): CachedSuggestions? { ): CachedSuggestions? {
loadFromDisk(userId)
val key = cacheKey(userId, libraryId, itemKind) val key = cacheKey(userId, libraryId, itemKind)
memoryCache[key]?.let { return it } return memoryCache.getOrPut(key) {
return withContext(Dispatchers.IO) { try {
runCatching { mutex.withLock {
File(cacheDir, "$key.json") File(cacheDir, "$key.json").inputStream().use {
.takeIf { it.exists() }
?.inputStream()
?.use {
json.decodeFromStream<CachedSuggestions>(it) json.decodeFromStream<CachedSuggestions>(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( suspend fun put(
userId: UUID, userId: UUID,
libraryId: UUID, libraryId: UUID,
@ -142,75 +81,23 @@ class SuggestionsCache
ids: List<UUID>, ids: List<UUID>,
) { ) {
val key = cacheKey(userId, libraryId, itemKind) val key = cacheKey(userId, libraryId, itemKind)
val cached = CachedSuggestions(ids) val suggestions = CachedSuggestions(ids)
val evictedEntry = memoryCache.put(key, suggestions)
try {
cacheDir.mkdirs()
mutex.withLock { mutex.withLock {
val evicted = checkForEviction(key) File(cacheDir, "$key.json").outputStream().use {
memoryCache[key] = cached json.encodeToStream(suggestions, it)
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")
} }
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 <T : Any> ObjectKache<T, *>.containsKey(key: T): Boolean = get(key) != null

View file

@ -80,13 +80,7 @@ class SuggestionsSchedulerService
BackoffPolicy.EXPONENTIAL, BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(), 15.minutes.toJavaDuration(),
).setInputData(inputData) ).setInputData(inputData)
.setInitialDelay(60.seconds.toJavaDuration())
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")
}
workManager.enqueueUniquePeriodicWork( workManager.enqueueUniquePeriodicWork(
uniqueWorkName = SuggestionsWorker.WORK_NAME, uniqueWorkName = SuggestionsWorker.WORK_NAME,

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
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.ApiClientException import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.userViewsApi 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 org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import kotlin.coroutines.CoroutineContext
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
@ -79,6 +81,7 @@ class SuggestionsWorker
} }
val results = val results =
supervisorScope { supervisorScope {
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
views views
.mapNotNull { view -> .mapNotNull { view ->
val itemKind = val itemKind =
@ -87,7 +90,14 @@ class SuggestionsWorker
async(Dispatchers.IO) { async(Dispatchers.IO) {
runCatching { runCatching {
Timber.v("Fetching suggestions for view %s", view.id) 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() ensureActive()
cache.put( cache.put(
userId, userId,
@ -107,7 +117,6 @@ class SuggestionsWorker
} }
val successCount = results.count { it.isSuccess } val successCount = results.count { it.isSuccess }
val failureCount = results.count { it.isFailure } val failureCount = results.count { it.isFailure }
cache.save()
if (failureCount > 0 && successCount == 0) { if (failureCount > 0 && successCount == 0) {
Timber.w("All attempts failed ($failureCount views), scheduling retry") Timber.w("All attempts failed ($failureCount views), scheduling retry")
return Result.retry() return Result.retry()
@ -124,6 +133,7 @@ class SuggestionsWorker
} }
private suspend fun fetchSuggestions( private suspend fun fetchSuggestions(
coroutineContext: CoroutineContext,
parentId: UUID, parentId: UUID,
userId: UUID, userId: UUID,
itemKind: BaseItemKind, itemKind: BaseItemKind,
@ -138,7 +148,7 @@ class SuggestionsWorker
val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1) val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
val historyDeferred = val historyDeferred =
async(Dispatchers.IO) { async(coroutineContext) {
fetchItems( fetchItems(
parentId = parentId, parentId = parentId,
userId = userId, userId = userId,
@ -160,7 +170,7 @@ class SuggestionsWorker
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId } val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
val contextualDeferred = val contextualDeferred =
async(Dispatchers.IO) { async(coroutineContext) {
if (allGenreIds.isEmpty()) { if (allGenreIds.isEmpty()) {
emptyList() emptyList()
} else { } else {
@ -178,7 +188,7 @@ class SuggestionsWorker
} }
val randomDeferred = val randomDeferred =
async(Dispatchers.IO) { async(coroutineContext) {
fetchItems( fetchItems(
parentId = parentId, parentId = parentId,
userId = userId, userId = userId,
@ -190,7 +200,7 @@ class SuggestionsWorker
} }
val freshDeferred = val freshDeferred =
async(Dispatchers.IO) { async(coroutineContext) {
fetchItems( fetchItems(
parentId = parentId, parentId = parentId,
userId = userId, userId = userId,
@ -201,18 +211,19 @@ class SuggestionsWorker
limit = freshLimit, limit = freshLimit,
) )
} }
withContext(Dispatchers.Default) {
val contextual = contextualDeferred.await()
val random = randomDeferred.await()
val fresh = freshDeferred.await()
val contextual = contextualDeferred.await() (contextual + fresh + random)
val random = randomDeferred.await() .asSequence()
val fresh = freshDeferred.await() .distinctBy { it.id }
.filterNot { excludeIds.contains(it.relevantId) }
(contextual + fresh + random) .toList()
.asSequence() .shuffled()
.distinctBy { it.id } .take(itemsPerRow)
.filterNot { excludeIds.contains(it.relevantId) } }
.toList()
.shuffled()
.take(itemsPerRow)
} }
private suspend fun fetchItems( private suspend fun fetchItems(

View file

@ -21,6 +21,7 @@ import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
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.toJavaDuration import kotlin.time.toJavaDuration
@ActivityScoped @ActivityScoped
@ -60,7 +61,8 @@ class TvProviderSchedulerService
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
), ),
).build(), ).setInitialDelay(60.seconds.toJavaDuration())
.build(),
).await() ).await()
} }
} }

View file

@ -80,6 +80,7 @@ class TvProviderWorker
getPotentialItems( getPotentialItems(
userId, userId,
prefs.homePagePreferences.enableRewatchingNextUp, prefs.homePagePreferences.enableRewatchingNextUp,
prefs.homePagePreferences.maxDaysNextUp,
) )
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
@ -143,12 +144,13 @@ class TvProviderWorker
private suspend fun getPotentialItems( private suspend fun getPotentialItems(
userId: UUID, userId: UUID,
enableRewatching: Boolean, enableRewatching: Boolean,
maxDaysNextUp: Int,
): List<BaseItem> { ): List<BaseItem> {
val resumeItems = latestNextUpService.getResume(userId, 10, true) val resumeItems = latestNextUpService.getResume(userId, 10, true)
val seriesIds = resumeItems.mapNotNull { it.data.seriesId } val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
val nextUpItems = val nextUpItems =
latestNextUpService latestNextUpService
.getNextUp(userId, 10, enableRewatching, false) .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
return latestNextUpService.buildCombined(resumeItems, nextUpItems) return latestNextUpService.buildCombined(resumeItems, nextUpItems)
} }

View file

@ -81,6 +81,18 @@ fun HomeSettingsGlobal(
modifier = Modifier, 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 { HorizontalDivider() }
item { item {
HomeSettingsListItem( HomeSettingsListItem(

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.saveable.rememberSerializable
@ -231,6 +232,7 @@ fun ApplicationContent(
) )
} }
} }
val navDrawerListState = rememberLazyListState()
NavDisplay( NavDisplay(
backStack = navigationManager.backStack, backStack = navigationManager.backStack,
onBack = { navigationManager.goBack() }, onBack = { navigationManager.goBack() },
@ -257,6 +259,7 @@ fun ApplicationContent(
user = user, user = user,
server = server, server = server,
drawerState = drawerState, drawerState = drawerState,
navDrawerListState = navDrawerListState,
onClearBackdrop = viewModel::clearBackdrop, onClearBackdrop = viewModel::clearBackdrop,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )

View file

@ -18,8 +18,8 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Home 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.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView 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.theme.LocalTheme
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.imageApi
@ -272,6 +269,7 @@ fun NavDrawer(
user: JellyfinUser, user: JellyfinUser,
server: JellyfinServer, server: JellyfinServer,
drawerState: DrawerState, drawerState: DrawerState,
navDrawerListState: LazyListState,
onClearBackdrop: () -> Unit, onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel = viewModel: NavDrawerViewModel =
@ -320,8 +318,6 @@ fun NavDrawer(
visibilityThreshold = IntOffset.VisibilityThreshold, visibilityThreshold = IntOffset.VisibilityThreshold,
), ),
) )
val config = LocalConfiguration.current
val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } }
ModalNavigationDrawer( ModalNavigationDrawer(
modifier = modifier, modifier = modifier,
@ -329,28 +325,8 @@ fun NavDrawer(
drawerContent = { drawerValue -> drawerContent = { drawerValue ->
val isOpen = drawerValue.isOpen val isOpen = drawerValue.isOpen
val spacedBy = 4.dp val spacedBy = 4.dp
val listState = rememberLazyListState()
val searchFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() }
suspend fun scrollToSelected() {
val target = selectedIndex + 2
try {
if (target !in
listState.firstVisibleItemIndex..<listState.layoutInfo.visibleItemsInfo.lastIndex
) {
val mult =
if ((target - 2) < listState.layoutInfo.totalItemsCount / 2) -1 else 1
listState.animateScrollToItem(selectedIndex + 2, mult * (heightInPx / 2))
}
} catch (ex: Exception) {
Timber.w(ex, "Error scrolling to %s", target)
}
}
LaunchedEffect(selectedIndex) {
scrollToSelected()
}
ProvideTextStyle(MaterialTheme.typography.labelMedium) { ProvideTextStyle(MaterialTheme.typography.labelMedium) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -374,7 +350,7 @@ fun NavDrawer(
modifier = Modifier, modifier = Modifier,
) )
LazyColumn( LazyColumn(
state = listState, state = navDrawerListState,
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedByWithFooter(spacedBy), verticalArrangement = Arrangement.spacedByWithFooter(spacedBy),
@ -389,11 +365,6 @@ fun NavDrawer(
focusRequester.tryRequestFocus() focusRequester.tryRequestFocus()
} }
} }
onExit = {
scope.launch(ExceptionHandler()) {
scrollToSelected()
}
}
}.fillMaxHeight(), }.fillMaxHeight(),
) { ) {
item { item {

View file

@ -65,7 +65,7 @@ fun SliderPreference(
} }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier =
Modifier Modifier

View file

@ -82,6 +82,7 @@ message HomePagePreferences{
int32 max_items_per_row = 1; int32 max_items_per_row = 1;
bool enable_rewatching_next_up = 2; bool enable_rewatching_next_up = 2;
bool combine_continue_next = 3; bool combine_continue_next = 3;
int32 max_days_next_up = 4;
} }
enum ThemeSongVolume { enum ThemeSongVolume {

View file

@ -260,6 +260,11 @@
<item quantity="one">%d hour</item> <item quantity="one">%d hour</item>
<item quantity="other">%d hours</item> <item quantity="other">%d hours</item>
</plurals> </plurals>
<plurals name="days">
<item quantity="zero">%s days</item>
<item quantity="one">%s day</item>
<item quantity="other">%s days</item>
</plurals>
<plurals name="items"> <plurals name="items">
<item quantity="zero">%d items</item> <item quantity="zero">%d items</item>
<item quantity="one">%d item</item> <item quantity="one">%d item</item>
@ -485,6 +490,8 @@
<string name="slideshow_duration">Slideshow duration</string> <string name="slideshow_duration">Slideshow duration</string>
<string name="play_videos_during_slideshow">Play videos during slideshow</string> <string name="play_videos_during_slideshow">Play videos during slideshow</string>
<string name="send_media_info_log_to_server">Send media info log to server</string> <string name="send_media_info_log_to_server">Send media info log to server</string>
<string name="no_limit">No limit</string>
<string name="max_days_next_up">Max days in Next Up</string>
<string name="add_row">Add row</string> <string name="add_row">Add row</string>
<string name="genres_in">Genres in %1$s</string> <string name="genres_in">Genres in %1$s</string>

View file

@ -12,7 +12,6 @@ import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@ -86,7 +85,6 @@ class SuggestionServiceTest {
runTest { runTest {
val currentUser = MutableLiveData<JellyfinUser?>(null) val currentUser = MutableLiveData<JellyfinUser?>(null)
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
val service = createService() val service = createService()
@ -104,7 +102,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
flowOf(listOf(mockWorkInfo(state))) flowOf(listOf(mockWorkInfo(state)))
@ -125,7 +122,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
flowOf(listOf(mockWorkInfo(state))) flowOf(listOf(mockWorkInfo(state)))
@ -145,7 +141,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
@ -164,7 +159,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
@ -199,7 +193,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
val cachedId = UUID.randomUUID() val cachedId = UUID.randomUUID()
coEvery { coEvery {
@ -237,7 +230,6 @@ class SuggestionServiceTest {
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
every { mockServerRepository.currentUser } returns currentUser every { mockServerRepository.currentUser } returns currentUser
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
val cachedId = UUID.randomUUID() val cachedId = UUID.randomUUID()
coEvery { coEvery {

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import com.mayakapps.kache.ObjectKache
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -28,11 +29,11 @@ class SuggestionsCacheTest {
return SuggestionsCache(mockContext) return SuggestionsCache(mockContext)
} }
private fun memoryCacheOf(cache: SuggestionsCache): MutableMap<String, CachedSuggestions> { private fun memoryCacheOf(cache: SuggestionsCache): ObjectKache<String, CachedSuggestions> {
val field = SuggestionsCache::class.java.getDeclaredField("memoryCache") val field = SuggestionsCache::class.java.getDeclaredField("memoryCache")
field.isAccessible = true field.isAccessible = true
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return field.get(cache) as MutableMap<String, CachedSuggestions> return field.get(cache) as ObjectKache<String, CachedSuggestions>
} }
@Test @Test
@ -57,7 +58,6 @@ class SuggestionsCacheTest {
val libId = UUID.randomUUID() val libId = UUID.randomUUID()
cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList()) cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList())
cache1.save()
// Create a fresh instance which won't have the memory entry // Create a fresh instance which won't have the memory entry
val cache2 = testCacheWithTempDir() val cache2 = testCacheWithTempDir()
@ -192,7 +192,6 @@ class SuggestionsCacheTest {
val cache1 = testCacheWithTempDir() val cache1 = testCacheWithTempDir()
cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1) cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1)
cache1.put(userId, lib2, BaseItemKind.SERIES, ids2) cache1.put(userId, lib2, BaseItemKind.SERIES, ids2)
cache1.save()
// Read with fresh cache instance (empty memory cache, reads from disk) // Read with fresh cache instance (empty memory cache, reads from disk)
val cache2 = testCacheWithTempDir() val cache2 = testCacheWithTempDir()

View file

@ -12,7 +12,6 @@ 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 io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
@ -30,7 +29,6 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class SuggestionsSchedulerServiceTest { class SuggestionsSchedulerServiceTest {
@ -65,7 +63,6 @@ class SuggestionsSchedulerServiceTest {
@Test @Test
fun schedules_periodic_work_when_user_present() = fun schedules_periodic_work_when_user_present() =
runTest { runTest {
coEvery { mockCache.isEmpty() } returns false
createService() createService()
currentLiveData.value = currentLiveData.value =
CurrentUser( CurrentUser(
@ -79,7 +76,6 @@ class SuggestionsSchedulerServiceTest {
@Test @Test
fun cancels_work_when_user_null() = fun cancels_work_when_user_null() =
runTest { runTest {
coEvery { mockCache.isEmpty() } returns false
createService() createService()
currentLiveData.value = currentLiveData.value =
CurrentUser( CurrentUser(
@ -95,7 +91,6 @@ class SuggestionsSchedulerServiceTest {
@Test @Test
fun schedules_periodic_work_with_delay_when_cache_empty() = fun schedules_periodic_work_with_delay_when_cache_empty() =
runTest { runTest {
coEvery { mockCache.isEmpty() } returns true
val workRequestSlot = slot<PeriodicWorkRequest>() val workRequestSlot = slot<PeriodicWorkRequest>()
every { every {
mockWorkManager.enqueueUniquePeriodicWork( mockWorkManager.enqueueUniquePeriodicWork(
@ -114,13 +109,12 @@ class SuggestionsSchedulerServiceTest {
advanceUntilIdle() advanceUntilIdle()
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
assertEquals(30000L, workRequestSlot.captured.workSpec.initialDelay) assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
} }
@Test @Test
fun schedules_periodic_work_without_delay_when_cache_not_empty() = fun schedules_periodic_work_with_delay_when_cache_not_empty() =
runTest { runTest {
coEvery { mockCache.isEmpty() } returns false
val workRequestSlot = slot<PeriodicWorkRequest>() val workRequestSlot = slot<PeriodicWorkRequest>()
every { every {
mockWorkManager.enqueueUniquePeriodicWork( mockWorkManager.enqueueUniquePeriodicWork(
@ -139,6 +133,6 @@ class SuggestionsSchedulerServiceTest {
advanceUntilIdle() advanceUntilIdle()
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
assertEquals(0L, workRequestSlot.captured.workSpec.initialDelay) assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
} }
} }

View file

@ -78,11 +78,6 @@ class SuggestionsWorkerTest {
every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 } every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 }
} }
private fun mockQueryResult(items: List<BaseItemDto> = emptyList()) =
mockk<org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult>>(relaxed = true) {
every { content } returns mockk { every { this@mockk.items } returns items }
}
@Test @Test
fun returns_failure_on_invalid_input() = fun returns_failure_on_invalid_input() =
runTest { runTest {
@ -137,7 +132,6 @@ class SuggestionsWorkerTest {
assertEquals(ListenableWorker.Result.success(), result) assertEquals(ListenableWorker.Result.success(), result)
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) } coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
coVerify { mockCache.save() }
} }
} }
@ -231,3 +225,8 @@ class SuggestionsWorkerTest {
assertEquals(ListenableWorker.Result.retry(), result) assertEquals(ListenableWorker.Result.retry(), result)
} }
} }
fun mockQueryResult(items: List<BaseItemDto> = emptyList()) =
mockk<org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult>>(relaxed = true) {
every { content } returns mockk { every { this@mockk.items } returns items }
}

View file

@ -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<TvShowsApi>()
private val mockApi = mockk<ApiClient>(relaxed = true)
private val mockContext = mockk<Context>()
private val mockDatePlayedService = mockk<DatePlayedService>()
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<GetNextUpRequest>()
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<GetNextUpRequest>()
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)
}
}
}

View file

@ -8,9 +8,10 @@ desugar_jdk_libs = "2.1.5"
hiltCompiler = "1.3.0" hiltCompiler = "1.3.0"
hiltNavigationCompose = "1.3.0" hiltNavigationCompose = "1.3.0"
hiltWork = "1.3.0" hiltWork = "1.3.0"
kache = "2.1.1"
kotlin = "2.3.0" kotlin = "2.3.0"
kotlinxCoroutinesCore = "1.10.2" kotlinxCoroutinesCore = "1.10.2"
ksp = "2.3.0" ksp = "2.3.5"
coreKtx = "1.17.0" coreKtx = "1.17.0"
appcompat = "1.7.1" appcompat = "1.7.1"
composeBom = "2026.01.01" composeBom = "2026.01.01"
@ -25,7 +26,7 @@ tvFoundation = "1.0.0-alpha12"
tvMaterial = "1.0.1" tvMaterial = "1.0.1"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.3" activityCompose = "1.12.3"
androidx-media3 = "1.9.1" androidx-media3 = "1.9.2"
coil = "3.3.0" coil = "3.3.0"
jellyfin-sdk = "1.7.1" jellyfin-sdk = "1.7.1"
nav3Core = "1.0.0" 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" } 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 = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", 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" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }

41
renovate.json Normal file
View file

@ -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**"
]
}
]
}