diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt new file mode 100644 index 000000000..d2c141b3e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt @@ -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>(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 { + 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() + 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<*>, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index 091edd217..d94d4f5cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -44,6 +44,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService @@ -63,7 +64,6 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -117,6 +117,7 @@ class CollectionFolderViewModel private val musicService: MusicService, val streamChoiceService: StreamChoiceService, val mediaReportService: MediaReportService, + private val filterOptionCache: FilterOptionCache, @Assisted val itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @Assisted("recursive") private val recursive: Boolean, @@ -430,9 +431,7 @@ class CollectionFolderViewModel } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser?.id, + filterOptionCache.getFilterOptionValues( itemId.toUUID(), filterOption, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 7a78701ab..0557edbc8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -64,6 +64,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MusicService @@ -101,7 +102,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler @@ -142,6 +142,7 @@ class PlaylistViewModel private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val favoriteWatchManager: FavoriteWatchManager, private val mediaReportService: MediaReportService, + private val filterOptionCache: FilterOptionCache, @Assisted itemId: UUID, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory @@ -301,9 +302,7 @@ class PlaylistViewModel } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser?.id, + filterOptionCache.getFilterOptionValues( itemId, filterOption, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index 0cc58beae..3b778ce6f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.KeyValueService import com.github.damontecres.wholphin.services.MediaManagementService @@ -33,7 +34,6 @@ import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler @@ -90,6 +90,7 @@ class CollectionViewModel private val imageUrlService: ImageUrlService, private val musicService: MusicService, val mediaReportService: MediaReportService, + private val filterOptionCache: FilterOptionCache, @Assisted private val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -352,9 +353,7 @@ class CollectionViewModel } suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser?.id, + filterOptionCache.getFilterOptionValues( itemId, filterOption, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt deleted file mode 100644 index a39cae25b..000000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -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.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 java.util.UUID - -object FilterUtils { - /** - * Gets the possible values for a filter - * - * For example, the possible genres in the parent ID - */ - suspend fun getFilterOptionValues( - api: ApiClient, - userId: UUID?, - parentId: UUID?, - filterOption: ItemFilterBy<*>, - ): List = - withContext(Dispatchers.IO) { - try { - 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 -> { - api.localizationApi.getParentalRatings().content.map { - FilterValueOption(it.name ?: "", it.value) - } - } - - 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() - 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) - } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Exception get filter value options for $filterOption") - listOf() - } - } -}