mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into develop/customize-home
This commit is contained in:
commit
9fa169a712
26 changed files with 368 additions and 264 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
41
renovate.json
Normal 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**"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue