Only show relevant parental ratings when filtering (#1529)

## Description
Updates the parental rating filter so it only shows the relevant options
instead of all of them. This is now the same behavior as the web UI.

Additionally, the filter options are cached for a short time so the UI
doesn't need to load them every time the menu is opened.

Dev note: Refactors `FilterUtils` into a service which allows for the
caching

### Related issues
Closes #410

### Testing
Emulator with movie & tv show libraries

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-06-07 07:15:40 -04:00 • committed by GitHub
parent d1b8c09615
commit a4ee1e5175
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 192 additions and 138 deletions

View file

@ -0,0 +1,183 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter
import com.github.damontecres.wholphin.data.filter.DecadeFilter
import com.github.damontecres.wholphin.data.filter.FavoriteFilter
import com.github.damontecres.wholphin.data.filter.FilterValueOption
import com.github.damontecres.wholphin.data.filter.FilterVideoType
import com.github.damontecres.wholphin.data.filter.GenreFilter
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
import com.github.damontecres.wholphin.data.filter.PlayedFilter
import com.github.damontecres.wholphin.data.filter.StudioFilter
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
import com.github.damontecres.wholphin.data.filter.YearFilter
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.showToast
import com.mayakapps.kache.InMemoryKache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.filterApi
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.localizationApi
import org.jellyfin.sdk.api.client.extensions.studiosApi
import org.jellyfin.sdk.api.client.extensions.yearsApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import timber.log.Timber
import java.util.TreeSet
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
/**
* Get the possible values for filters in a library
*/
@Singleton
class FilterOptionCache
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:IoCoroutineScope private val ioCoroutineScope: CoroutineScope,
private val api: ApiClient,
private val serverRepository: ServerRepository,
) {
private val cache =
InMemoryKache<FilterOptionCacheKey, List<FilterValueOption>>(16) {
creationScope = ioCoroutineScope
expireAfterWriteDuration = 1.hours
}
/**
* Gets the possible values for a filter
*
* For example, the possible genres in the parent ID
*/
suspend fun getFilterOptionValues(
parentId: UUID?,
filterOption: ItemFilterBy<*>,
): List<FilterValueOption> {
val cacheKey = FilterOptionCacheKey(serverRepository.currentUser?.id, parentId, filterOption)
return try {
cache
.getOrPut(cacheKey) { (userId, parentId, filterOption) ->
getFilterOptionValues(userId, parentId, filterOption)
}.orEmpty()
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error fetching options for %s", filterOption)
showToast(context, "Error occurred: ${ex.localizedMessage}")
emptyList()
}
}
private suspend fun getFilterOptionValues(
userId: UUID?,
parentId: UUID?,
filterOption: ItemFilterBy<*>,
) = when (filterOption) {
GenreFilter -> {
api.genresApi
.getGenres(
parentId = parentId,
userId = userId,
).content.items
.map { FilterValueOption(it.name ?: "", it.id) }
}
StudioFilter -> {
api.studiosApi
.getStudios(
parentId = parentId,
userId = userId,
includeItemTypes = listOf(BaseItemKind.SERIES),
).content.items
.map { FilterValueOption(it.name ?: "", it.id) }
}
FavoriteFilter,
PlayedFilter,
-> {
listOf(
FilterValueOption("True", null),
FilterValueOption("False", null),
)
}
OfficialRatingFilter -> {
val ratings =
api.localizationApi
.getParentalRatings()
.content
.associate {
it.name to it.value
}
api.filterApi
.getQueryFiltersLegacy(
parentId = parentId,
userId = userId,
).content.officialRatings
?.mapNotNull { r ->
val value = ratings[r]
value?.let { FilterValueOption(r, value) }
}?.sortedBy { it.value as Int }
.orEmpty()
}
VideoTypeFilter -> {
FilterVideoType.entries.map {
FilterValueOption(it.readable, it)
}
}
YearFilter -> {
api.yearsApi
.getYears(
parentId = parentId,
userId = userId,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
).content.items
.mapNotNull {
it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) }
}
}
DecadeFilter -> {
val items = TreeSet<Int>()
api.yearsApi
.getYears(
parentId = parentId,
userId = userId,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
).content.items
.mapNotNullTo(items) {
it.name
?.toIntOrNull()
?.div(10)
?.times(10)
}
items.toList().sorted().map { FilterValueOption("$it's", it) }
}
CommunityRatingFilter -> {
(1..10).map {
FilterValueOption("$it", it)
}
}
}
private data class FilterOptionCacheKey(
val userId: UUID?,
val parentId: UUID?,
val filterOption: ItemFilterBy<*>,
)
}