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)
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kache)
|
||||
implementation(libs.kache.file)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
|
|
|||
|
|
@ -186,6 +186,45 @@ sealed interface AppPreference<Pref, T> {
|
|||
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 =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.combine_continue_next,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ class AppPreferencesSerializer
|
|||
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
|
||||
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
|
||||
combineContinueNext = AppPreference.CombineContinueNext.defaultValue
|
||||
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
|
||||
}.build()
|
||||
interfacePreferences =
|
||||
InterfacePreferences
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreference
|
|||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.update
|
||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||
|
|
@ -237,4 +238,12 @@ suspend fun upgradeApp(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateHomePagePreferences {
|
||||
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -555,6 +555,7 @@ class HomeSettingsService
|
|||
limit,
|
||||
prefs.enableRewatchingNextUp,
|
||||
false,
|
||||
prefs.maxDaysNextUp,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
|
||||
|
|
@ -579,6 +580,7 @@ class HomeSettingsService
|
|||
limit,
|
||||
prefs.enableRewatchingNextUp,
|
||||
false,
|
||||
prefs.maxDaysNextUp,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -75,8 +75,11 @@ class LatestNextUpService
|
|||
limit: Int,
|
||||
enableRewatching: Boolean,
|
||||
enableResumable: Boolean,
|
||||
maxDays: Int,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
): List<BaseItem> {
|
||||
val nextUpDateCutoff =
|
||||
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
|
||||
val request =
|
||||
GetNextUpRequest(
|
||||
userId = userId,
|
||||
|
|
@ -87,6 +90,7 @@ class LatestNextUpService
|
|||
enableResumable = enableResumable,
|
||||
enableUserData = true,
|
||||
enableRewatching = enableRewatching,
|
||||
nextUpDateCutoff = nextUpDateCutoff,
|
||||
)
|
||||
val nextUp =
|
||||
api.tvShowsApi
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
|||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
|
@ -19,7 +16,6 @@ class PlaybackLifecycleObserver
|
|||
private val navigationManager: NavigationManager,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val suggestionsCache: SuggestionsCache,
|
||||
) : DefaultLifecycleObserver {
|
||||
private var wasPlaying: Boolean? = null
|
||||
|
||||
|
|
@ -52,6 +48,5 @@ class PlaybackLifecycleObserver
|
|||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
themeSongPlayer.stop()
|
||||
CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -50,32 +49,31 @@ class SuggestionService
|
|||
.asFlow()
|
||||
.flatMapLatest { user ->
|
||||
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
|
||||
|
||||
cache.cacheVersion
|
||||
.map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { cachedIds ->
|
||||
if (cachedIds.isNotEmpty()) {
|
||||
flow {
|
||||
try {
|
||||
emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind)))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch items")
|
||||
emit(SuggestionsResource.Empty)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
workManager
|
||||
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
|
||||
.map { workInfos ->
|
||||
val isActive =
|
||||
workInfos.any {
|
||||
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
|
||||
}
|
||||
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
|
||||
}
|
||||
val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty()
|
||||
if (cachedIds.isNotEmpty()) {
|
||||
flow {
|
||||
try {
|
||||
emit(
|
||||
SuggestionsResource.Success(
|
||||
fetchItemsByIds(cachedIds, itemKind),
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to fetch items")
|
||||
emit(SuggestionsResource.Empty)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
workManager
|
||||
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
|
||||
.map { workInfos ->
|
||||
val isActive =
|
||||
workInfos.any {
|
||||
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
|
||||
}
|
||||
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,15 +4,13 @@ package com.github.damontecres.wholphin.services
|
|||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import com.mayakapps.kache.ObjectKache
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
|
|
@ -38,39 +36,15 @@ class SuggestionsCache
|
|||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val cacheDir: File
|
||||
get() = File(context.cacheDir, "suggestions")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val _cacheVersion = MutableStateFlow(0L)
|
||||
val cacheVersion: StateFlow<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()
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private fun writeEntryToDisk(
|
||||
key: String,
|
||||
cached: CachedSuggestions,
|
||||
) {
|
||||
runCatching {
|
||||
val suggestionsDir = cacheDir.apply { mkdirs() }
|
||||
File(suggestionsDir, "$key.json")
|
||||
.outputStream()
|
||||
.use { json.encodeToStream(cached, it) }
|
||||
}.onFailure { Timber.w(it, "Failed to write evicted cache: $key") }
|
||||
}
|
||||
|
||||
private fun checkForEviction(newKey: String): Pair<String, CachedSuggestions>? {
|
||||
if (memoryCache.containsKey(newKey) || memoryCache.size < MAX_MEMORY_CACHE_SIZE) {
|
||||
return null
|
||||
private val memoryCache =
|
||||
InMemoryKache<String, CachedSuggestions>(maxSize = 8) {
|
||||
creationScope = CoroutineScope(Dispatchers.IO)
|
||||
}
|
||||
val eldest = memoryCache.entries.firstOrNull() ?: return null
|
||||
memoryCache.remove(eldest.key)
|
||||
return if (dirtyKeys.remove(eldest.key)) eldest.key to eldest.value else null
|
||||
}
|
||||
|
||||
private fun cacheKey(
|
||||
userId: UUID,
|
||||
|
|
@ -78,63 +52,28 @@ class SuggestionsCache
|
|||
itemKind: BaseItemKind,
|
||||
) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}"
|
||||
|
||||
private val cacheDir: File
|
||||
get() = File(context.cacheDir, "suggestions")
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private suspend fun loadFromDisk(userId: UUID) {
|
||||
if (diskCacheLoadedUserId == userId) return
|
||||
mutex.withLock {
|
||||
if (diskCacheLoadedUserId == userId) return@withLock
|
||||
withContext(Dispatchers.IO) {
|
||||
val suggestionsDir = cacheDir
|
||||
if (!suggestionsDir.exists()) {
|
||||
diskCacheLoadedUserId = userId
|
||||
return@withContext
|
||||
}
|
||||
memoryCache.clear()
|
||||
suggestionsDir
|
||||
.listFiles {
|
||||
it.name.startsWith(userId.toServerString())
|
||||
}.orEmpty()
|
||||
.take(MAX_MEMORY_CACHE_SIZE)
|
||||
.forEach { file ->
|
||||
runCatching {
|
||||
val key = file.nameWithoutExtension
|
||||
val cached =
|
||||
file
|
||||
.inputStream()
|
||||
.use { json.decodeFromStream<CachedSuggestions>(it) }
|
||||
memoryCache[key] = cached
|
||||
}.onFailure { Timber.w(it, "Failed to read cache file: ${file.name}") }
|
||||
}
|
||||
diskCacheLoadedUserId = userId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun get(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
): CachedSuggestions? {
|
||||
loadFromDisk(userId)
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
memoryCache[key]?.let { return it }
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
File(cacheDir, "$key.json")
|
||||
.takeIf { it.exists() }
|
||||
?.inputStream()
|
||||
?.use {
|
||||
return memoryCache.getOrPut(key) {
|
||||
try {
|
||||
mutex.withLock {
|
||||
File(cacheDir, "$key.json").inputStream().use {
|
||||
json.decodeFromStream<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(
|
||||
userId: UUID,
|
||||
libraryId: UUID,
|
||||
|
|
@ -142,75 +81,23 @@ class SuggestionsCache
|
|||
ids: List<UUID>,
|
||||
) {
|
||||
val key = cacheKey(userId, libraryId, itemKind)
|
||||
val cached = CachedSuggestions(ids)
|
||||
val evictedEntry =
|
||||
val suggestions = CachedSuggestions(ids)
|
||||
memoryCache.put(key, suggestions)
|
||||
try {
|
||||
cacheDir.mkdirs()
|
||||
mutex.withLock {
|
||||
val evicted = checkForEviction(key)
|
||||
memoryCache[key] = cached
|
||||
dirtyKeys.add(key)
|
||||
_cacheVersion.update { it + 1 }
|
||||
evicted
|
||||
}
|
||||
evictedEntry?.let { (evictedKey, evictedValue) ->
|
||||
withContext(Dispatchers.IO) {
|
||||
writeEntryToDisk(evictedKey, evictedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isEmpty(): Boolean =
|
||||
mutex.withLock {
|
||||
if (memoryCache.isNotEmpty() || dirtyKeys.isNotEmpty()) {
|
||||
return@withLock false
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
val files = cacheDir.listFiles()
|
||||
files == null || files.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun save() {
|
||||
val entriesToSave =
|
||||
mutex.withLock {
|
||||
if (dirtyKeys.isEmpty()) return
|
||||
val entries =
|
||||
dirtyKeys.mapNotNull { key ->
|
||||
memoryCache[key]?.let { key to it }
|
||||
}
|
||||
dirtyKeys.clear()
|
||||
entries
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val suggestionsDir =
|
||||
cacheDir.apply {
|
||||
if (!mkdirs() && !exists()) Timber.w("Failed to create suggestions cache directory")
|
||||
File(cacheDir, "$key.json").outputStream().use {
|
||||
json.encodeToStream(suggestions, it)
|
||||
}
|
||||
entriesToSave.forEach { (key, value) ->
|
||||
runCatching {
|
||||
File(suggestionsDir, "$key.json")
|
||||
.outputStream()
|
||||
.use { json.encodeToStream(value, it) }
|
||||
}.onFailure { Timber.w(it, "Failed to write cache: $key") }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception writing to disk cache")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
mutex.withLock {
|
||||
memoryCache.clear()
|
||||
dirtyKeys.clear()
|
||||
_cacheVersion.update { it + 1 }
|
||||
diskCacheLoadedUserId = null
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { cacheDir.deleteRecursively() }
|
||||
.onFailure { Timber.w(it, "Failed to clear suggestions cache") }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_MEMORY_CACHE_SIZE = 8
|
||||
}
|
||||
}
|
||||
|
||||
fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L
|
||||
|
||||
fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty()
|
||||
|
||||
suspend fun <T : Any> ObjectKache<T, *>.containsKey(key: T): Boolean = get(key) != null
|
||||
|
|
|
|||
|
|
@ -80,13 +80,7 @@ class SuggestionsSchedulerService
|
|||
BackoffPolicy.EXPONENTIAL,
|
||||
15.minutes.toJavaDuration(),
|
||||
).setInputData(inputData)
|
||||
|
||||
if (cache.isEmpty()) {
|
||||
Timber.i("Suggestions cache empty, scheduling periodic fetch with 30s delay")
|
||||
periodicWorkRequestBuilder.setInitialDelay(30.seconds.toJavaDuration())
|
||||
} else {
|
||||
Timber.i("Scheduling periodic SuggestionsWorker")
|
||||
}
|
||||
.setInitialDelay(60.seconds.toJavaDuration())
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import kotlinx.coroutines.coroutineScope
|
|||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
|
|
@ -31,6 +32,7 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
|||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
private val BaseItemDto.relevantId: UUID get() = seriesId ?: id
|
||||
|
||||
|
|
@ -79,6 +81,7 @@ class SuggestionsWorker
|
|||
}
|
||||
val results =
|
||||
supervisorScope {
|
||||
val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions")
|
||||
views
|
||||
.mapNotNull { view ->
|
||||
val itemKind =
|
||||
|
|
@ -87,7 +90,14 @@ class SuggestionsWorker
|
|||
async(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Timber.v("Fetching suggestions for view %s", view.id)
|
||||
val suggestions = fetchSuggestions(view.id, userId, itemKind, itemsPerRow)
|
||||
val suggestions =
|
||||
fetchSuggestions(
|
||||
context,
|
||||
view.id,
|
||||
userId,
|
||||
itemKind,
|
||||
itemsPerRow,
|
||||
)
|
||||
ensureActive()
|
||||
cache.put(
|
||||
userId,
|
||||
|
|
@ -107,7 +117,6 @@ class SuggestionsWorker
|
|||
}
|
||||
val successCount = results.count { it.isSuccess }
|
||||
val failureCount = results.count { it.isFailure }
|
||||
cache.save()
|
||||
if (failureCount > 0 && successCount == 0) {
|
||||
Timber.w("All attempts failed ($failureCount views), scheduling retry")
|
||||
return Result.retry()
|
||||
|
|
@ -124,6 +133,7 @@ class SuggestionsWorker
|
|||
}
|
||||
|
||||
private suspend fun fetchSuggestions(
|
||||
coroutineContext: CoroutineContext,
|
||||
parentId: UUID,
|
||||
userId: UUID,
|
||||
itemKind: BaseItemKind,
|
||||
|
|
@ -138,7 +148,7 @@ class SuggestionsWorker
|
|||
val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
|
||||
|
||||
val historyDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
async(coroutineContext) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
|
|
@ -160,7 +170,7 @@ class SuggestionsWorker
|
|||
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
|
||||
|
||||
val contextualDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
async(coroutineContext) {
|
||||
if (allGenreIds.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
|
|
@ -178,7 +188,7 @@ class SuggestionsWorker
|
|||
}
|
||||
|
||||
val randomDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
async(coroutineContext) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
|
|
@ -190,7 +200,7 @@ class SuggestionsWorker
|
|||
}
|
||||
|
||||
val freshDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
async(coroutineContext) {
|
||||
fetchItems(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
|
|
@ -201,18 +211,19 @@ class SuggestionsWorker
|
|||
limit = freshLimit,
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
val contextual = contextualDeferred.await()
|
||||
val random = randomDeferred.await()
|
||||
val fresh = freshDeferred.await()
|
||||
|
||||
val contextual = contextualDeferred.await()
|
||||
val random = randomDeferred.await()
|
||||
val fresh = freshDeferred.await()
|
||||
|
||||
(contextual + fresh + random)
|
||||
.asSequence()
|
||||
.distinctBy { it.id }
|
||||
.filterNot { excludeIds.contains(it.relevantId) }
|
||||
.toList()
|
||||
.shuffled()
|
||||
.take(itemsPerRow)
|
||||
(contextual + fresh + random)
|
||||
.asSequence()
|
||||
.distinctBy { it.id }
|
||||
.filterNot { excludeIds.contains(it.relevantId) }
|
||||
.toList()
|
||||
.shuffled()
|
||||
.take(itemsPerRow)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchItems(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import timber.log.Timber
|
|||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@ActivityScoped
|
||||
|
|
@ -60,7 +61,8 @@ class TvProviderSchedulerService
|
|||
TvProviderWorker.PARAM_USER_ID to user.user.id.toString(),
|
||||
TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(),
|
||||
),
|
||||
).build(),
|
||||
).setInitialDelay(60.seconds.toJavaDuration())
|
||||
.build(),
|
||||
).await()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ class TvProviderWorker
|
|||
getPotentialItems(
|
||||
userId,
|
||||
prefs.homePagePreferences.enableRewatchingNextUp,
|
||||
prefs.homePagePreferences.maxDaysNextUp,
|
||||
)
|
||||
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
|
||||
|
||||
|
|
@ -143,12 +144,13 @@ class TvProviderWorker
|
|||
private suspend fun getPotentialItems(
|
||||
userId: UUID,
|
||||
enableRewatching: Boolean,
|
||||
maxDaysNextUp: Int,
|
||||
): List<BaseItem> {
|
||||
val resumeItems = latestNextUpService.getResume(userId, 10, true)
|
||||
val seriesIds = resumeItems.mapNotNull { it.data.seriesId }
|
||||
val nextUpItems =
|
||||
latestNextUpService
|
||||
.getNextUp(userId, 10, enableRewatching, false)
|
||||
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
|
||||
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
|
||||
return latestNextUpService.buildCombined(resumeItems, nextUpItems)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,18 @@ fun HomeSettingsGlobal(
|
|||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ComposablePreference(
|
||||
preference = AppPreference.MaxDaysNextUp,
|
||||
value = AppPreference.MaxDaysNextUp.getter.invoke(preferences),
|
||||
onValueChange = {
|
||||
val newPrefs = AppPreference.MaxDaysNextUp.setter.invoke(preferences, it)
|
||||
onPreferenceChange.invoke(newPrefs)
|
||||
},
|
||||
onNavigate = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
item {
|
||||
HomeSettingsListItem(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.saveable.rememberSerializable
|
||||
|
|
@ -231,6 +232,7 @@ fun ApplicationContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
val navDrawerListState = rememberLazyListState()
|
||||
NavDisplay(
|
||||
backStack = navigationManager.backStack,
|
||||
onBack = { navigationManager.goBack() },
|
||||
|
|
@ -257,6 +259,7 @@ fun ApplicationContent(
|
|||
user = user,
|
||||
server = server,
|
||||
drawerState = drawerState,
|
||||
navDrawerListState = navDrawerListState,
|
||||
onClearBackdrop = viewModel::clearBackdrop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import androidx.compose.foundation.layout.offset
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
|
|
@ -41,7 +41,6 @@ import androidx.compose.ui.focus.focusProperties
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
|
@ -88,12 +87,10 @@ import com.github.damontecres.wholphin.ui.spacedByWithFooter
|
|||
import com.github.damontecres.wholphin.ui.theme.LocalTheme
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
|
|
@ -272,6 +269,7 @@ fun NavDrawer(
|
|||
user: JellyfinUser,
|
||||
server: JellyfinServer,
|
||||
drawerState: DrawerState,
|
||||
navDrawerListState: LazyListState,
|
||||
onClearBackdrop: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: NavDrawerViewModel =
|
||||
|
|
@ -320,8 +318,6 @@ fun NavDrawer(
|
|||
visibilityThreshold = IntOffset.VisibilityThreshold,
|
||||
),
|
||||
)
|
||||
val config = LocalConfiguration.current
|
||||
val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
modifier = modifier,
|
||||
|
|
@ -329,28 +325,8 @@ fun NavDrawer(
|
|||
drawerContent = { drawerValue ->
|
||||
val isOpen = drawerValue.isOpen
|
||||
val spacedBy = 4.dp
|
||||
val listState = rememberLazyListState()
|
||||
val searchFocusRequester = remember { FocusRequester() }
|
||||
|
||||
suspend fun scrollToSelected() {
|
||||
val target = selectedIndex + 2
|
||||
try {
|
||||
if (target !in
|
||||
listState.firstVisibleItemIndex..<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) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
|
|
@ -374,7 +350,7 @@ fun NavDrawer(
|
|||
modifier = Modifier,
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
state = navDrawerListState,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedByWithFooter(spacedBy),
|
||||
|
|
@ -389,11 +365,6 @@ fun NavDrawer(
|
|||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
onExit = {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
scrollToSelected()
|
||||
}
|
||||
}
|
||||
}.fillMaxHeight(),
|
||||
) {
|
||||
item {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ fun SliderPreference(
|
|||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ message HomePagePreferences{
|
|||
int32 max_items_per_row = 1;
|
||||
bool enable_rewatching_next_up = 2;
|
||||
bool combine_continue_next = 3;
|
||||
int32 max_days_next_up = 4;
|
||||
}
|
||||
|
||||
enum ThemeSongVolume {
|
||||
|
|
|
|||
|
|
@ -260,6 +260,11 @@
|
|||
<item quantity="one">%d hour</item>
|
||||
<item quantity="other">%d hours</item>
|
||||
</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">
|
||||
<item quantity="zero">%d items</item>
|
||||
<item quantity="one">%d item</item>
|
||||
|
|
@ -485,6 +490,8 @@
|
|||
<string name="slideshow_duration">Slideshow duration</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="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="genres_in">Genres in %1$s</string>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import io.mockk.every
|
|||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
|
|
@ -86,7 +85,6 @@ class SuggestionServiceTest {
|
|||
runTest {
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(null)
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
|
|
@ -104,7 +102,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||
flowOf(listOf(mockWorkInfo(state)))
|
||||
|
|
@ -125,7 +122,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||
flowOf(listOf(mockWorkInfo(state)))
|
||||
|
|
@ -145,7 +141,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
|
|
@ -164,7 +159,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
|
||||
|
|
@ -199,7 +193,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
|
|
@ -237,7 +230,6 @@ class SuggestionServiceTest {
|
|||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.mayakapps.kache.ObjectKache
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -28,11 +29,11 @@ class SuggestionsCacheTest {
|
|||
return SuggestionsCache(mockContext)
|
||||
}
|
||||
|
||||
private fun memoryCacheOf(cache: SuggestionsCache): MutableMap<String, CachedSuggestions> {
|
||||
private fun memoryCacheOf(cache: SuggestionsCache): ObjectKache<String, CachedSuggestions> {
|
||||
val field = SuggestionsCache::class.java.getDeclaredField("memoryCache")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return field.get(cache) as MutableMap<String, CachedSuggestions>
|
||||
return field.get(cache) as ObjectKache<String, CachedSuggestions>
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -57,7 +58,6 @@ class SuggestionsCacheTest {
|
|||
val libId = UUID.randomUUID()
|
||||
|
||||
cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
cache1.save()
|
||||
|
||||
// Create a fresh instance which won't have the memory entry
|
||||
val cache2 = testCacheWithTempDir()
|
||||
|
|
@ -192,7 +192,6 @@ class SuggestionsCacheTest {
|
|||
val cache1 = testCacheWithTempDir()
|
||||
cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1)
|
||||
cache1.put(userId, lib2, BaseItemKind.SERIES, ids2)
|
||||
cache1.save()
|
||||
|
||||
// Read with fresh cache instance (empty memory cache, reads from disk)
|
||||
val cache2 = testCacheWithTempDir()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.CurrentUser
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
|
|
@ -30,7 +29,6 @@ import org.junit.Before
|
|||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SuggestionsSchedulerServiceTest {
|
||||
|
|
@ -65,7 +63,6 @@ class SuggestionsSchedulerServiceTest {
|
|||
@Test
|
||||
fun schedules_periodic_work_when_user_present() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
CurrentUser(
|
||||
|
|
@ -79,7 +76,6 @@ class SuggestionsSchedulerServiceTest {
|
|||
@Test
|
||||
fun cancels_work_when_user_null() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
CurrentUser(
|
||||
|
|
@ -95,7 +91,6 @@ class SuggestionsSchedulerServiceTest {
|
|||
@Test
|
||||
fun schedules_periodic_work_with_delay_when_cache_empty() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns true
|
||||
val workRequestSlot = slot<PeriodicWorkRequest>()
|
||||
every {
|
||||
mockWorkManager.enqueueUniquePeriodicWork(
|
||||
|
|
@ -114,13 +109,12 @@ class SuggestionsSchedulerServiceTest {
|
|||
advanceUntilIdle()
|
||||
|
||||
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
||||
assertEquals(30000L, workRequestSlot.captured.workSpec.initialDelay)
|
||||
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schedules_periodic_work_without_delay_when_cache_not_empty() =
|
||||
fun schedules_periodic_work_with_delay_when_cache_not_empty() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
val workRequestSlot = slot<PeriodicWorkRequest>()
|
||||
every {
|
||||
mockWorkManager.enqueueUniquePeriodicWork(
|
||||
|
|
@ -139,6 +133,6 @@ class SuggestionsSchedulerServiceTest {
|
|||
advanceUntilIdle()
|
||||
|
||||
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
||||
assertEquals(0L, workRequestSlot.captured.workSpec.initialDelay)
|
||||
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,11 +78,6 @@ class SuggestionsWorkerTest {
|
|||
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
|
||||
fun returns_failure_on_invalid_input() =
|
||||
runTest {
|
||||
|
|
@ -137,7 +132,6 @@ class SuggestionsWorkerTest {
|
|||
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
|
||||
coVerify { mockCache.save() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,3 +225,8 @@ class SuggestionsWorkerTest {
|
|||
assertEquals(ListenableWorker.Result.retry(), result)
|
||||
}
|
||||
}
|
||||
|
||||
fun mockQueryResult(items: List<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"
|
||||
hiltNavigationCompose = "1.3.0"
|
||||
hiltWork = "1.3.0"
|
||||
kache = "2.1.1"
|
||||
kotlin = "2.3.0"
|
||||
kotlinxCoroutinesCore = "1.10.2"
|
||||
ksp = "2.3.0"
|
||||
ksp = "2.3.5"
|
||||
coreKtx = "1.17.0"
|
||||
appcompat = "1.7.1"
|
||||
composeBom = "2026.01.01"
|
||||
|
|
@ -25,7 +26,7 @@ tvFoundation = "1.0.0-alpha12"
|
|||
tvMaterial = "1.0.1"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
activityCompose = "1.12.3"
|
||||
androidx-media3 = "1.9.1"
|
||||
androidx-media3 = "1.9.2"
|
||||
coil = "3.3.0"
|
||||
jellyfin-sdk = "1.7.1"
|
||||
nav3Core = "1.0.0"
|
||||
|
|
@ -80,6 +81,8 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers
|
|||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||
kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" }
|
||||
kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" }
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
||||
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
|
||||
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||
|
|
|
|||
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