Add ability to filter libraries, collection, etc (#308)

Adds a button to filter contents next to the sort button on grid pages

The filters are saved and restored you return to the page. It works with
#278, playing the filtered items in the sorted order.

The drop down menus for both sorting & filtering are updated to closer
match the overall look.

Filters for:
- Played or unplayed
- Favorite or not
- Genres
- Community (star) rating
- Parental rating
- Video type (HD vs SD, etc)
- Release year and/or decade
This commit is contained in:
damontecres 2025-11-23 10:40:18 -05:00 committed by GitHub
parent 50dc4b9a35
commit 78e7eb421f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1325 additions and 75 deletions

View file

@ -7,25 +7,29 @@ import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.util.UUID
@Database(
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class],
version = 7,
version = 8,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
AutoMigration(4, 5),
AutoMigration(5, 6),
AutoMigration(6, 7),
AutoMigration(7, 8),
],
)
@TypeConverters(Converters::class)
@ -57,6 +61,18 @@ class Converters {
@TypeConverter
fun convertSortOrder(sort: String): SortOrder? = SortOrder.fromNameOrNull(sort)
@TypeConverter
fun convertGetItemsFilter(filter: GetItemsFilter): String = Json.encodeToString(filter)
@TypeConverter
fun convertGetItemsFilter(filter: String): GetItemsFilter =
try {
Json.decodeFromString(filter)
} catch (ex: Exception) {
Timber.e(ex, "Error parsing filter")
GetItemsFilter()
}
}
object Migrations {

View file

@ -0,0 +1,6 @@
package com.github.damontecres.wholphin.data.filter
data class FilterValueOption(
val name: String,
val value: Any?,
)

View file

@ -0,0 +1,169 @@
package com.github.damontecres.wholphin.data.filter
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import java.util.UUID
val DefaultFilterOptions =
listOf(
PlayedFilter,
FavoriteFilter,
GenreFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
val DefaultForFavoritesFilterOptions =
listOf(
PlayedFilter,
GenreFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
val DefaultForGenresFilterOptions =
listOf(
PlayedFilter,
FavoriteFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
sealed interface ItemFilterBy<T> {
@get:StringRes
val stringRes: Int
val supportMultiple: Boolean
fun get(filter: GetItemsFilter): T?
fun set(
value: T?,
filter: GetItemsFilter,
): GetItemsFilter
}
data object GenreFilter : ItemFilterBy<List<UUID>> {
override val stringRes: Int = R.string.genres
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<UUID>? = filter.genres
override fun set(
value: List<UUID>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(genres = value)
}
data object PlayedFilter : ItemFilterBy<Boolean> {
override val stringRes: Int = R.string.played
override val supportMultiple: Boolean = false
override fun get(filter: GetItemsFilter): Boolean? = filter.played
override fun set(
value: Boolean?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(played = value)
}
data object FavoriteFilter : ItemFilterBy<Boolean> {
override val stringRes: Int = R.string.favorites
override val supportMultiple: Boolean = false
override fun get(filter: GetItemsFilter): Boolean? = filter.favorite
override fun set(
value: Boolean?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(favorite = value)
}
data object OfficialRatingFilter : ItemFilterBy<List<String>> {
override val stringRes: Int = R.string.official_rating
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<String>? = filter.officialRatings
override fun set(
value: List<String>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(officialRatings = value)
}
enum class FilterVideoType(
val readable: String,
) {
FOUR_K("4K"),
HD("HD"),
SD("SD"),
THREE_D("3D"),
BLU_RAY("Blu-Ray"),
DVD("DVD"),
}
data object VideoTypeFilter : ItemFilterBy<List<FilterVideoType>> {
override val stringRes: Int = R.string.video
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<FilterVideoType>? = filter.videoTypes
override fun set(
value: List<FilterVideoType>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(videoTypes = value)
}
data object YearFilter : ItemFilterBy<List<Int>> {
override val stringRes: Int = R.string.year
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<Int>? = filter.years
override fun set(
value: List<Int>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(years = value)
}
data object DecadeFilter : ItemFilterBy<List<Int>> {
override val stringRes: Int = R.string.decade
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<Int>? = filter.decades
override fun set(
value: List<Int>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(decades = value)
}
data object CommunityRatingFilter : ItemFilterBy<Int> {
override val stringRes: Int = R.string.community_rating
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): Int? = filter.minCommunityRating?.toInt()
override fun set(
value: Int?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble())
}

View file

@ -2,9 +2,13 @@
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.data.filter.FilterVideoType
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.VideoType
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
@ -14,30 +18,112 @@ import java.util.UUID
data class GetItemsFilter(
val favorite: Boolean? = null,
val genres: List<UUID>? = null,
val minCriticRating: Double? = null,
val minCommunityRating: Double? = null,
val officialRatings: List<String>? = null,
val persons: List<UUID>? = null,
val played: Boolean? = null,
val studios: List<UUID>? = null,
val tags: List<String>? = null,
val includeItemTypes: List<BaseItemKind>? = null,
val videoTypes: List<FilterVideoType>? = null,
val years: List<Int>? = null,
val decades: List<Int>? = null,
val override: GetItemsFilterOverride = GetItemsFilterOverride.NONE,
) {
/**
* Returns how many of filters are actually being used in this [GetItemsFilter]
*/
fun countFilters(filterOptions: List<ItemFilterBy<*>>): Int {
var count = 0
filterOptions.forEach {
if (it.get(this) != null) count++
}
return count
}
/**
* Clear all of the values for the given filters
*/
fun delete(filterOptions: List<ItemFilterBy<*>>): GetItemsFilter {
var newFilter = this
filterOptions.forEach {
newFilter = it.set(null, newFilter)
}
return newFilter
}
fun applyTo(req: GetItemsRequest) =
req.copy(
includeItemTypes = includeItemTypes,
isFavorite = favorite,
genreIds = genres,
minCriticRating = minCriticRating,
minCommunityRating = minCommunityRating,
personIds = persons,
isPlayed = played,
studioIds = studios,
tags = tags,
officialRatings = officialRatings,
years =
buildSet {
years?.letNotEmpty(::addAll)
decades?.forEach { addAll(it..<(it + 10)) }
},
is4k =
videoTypes?.letNotEmpty {
videoTypes.contains(FilterVideoType.FOUR_K).takeIf { it }
},
isHd =
videoTypes?.letNotEmpty {
if (videoTypes.contains(FilterVideoType.HD)) {
true
} else if (videoTypes.contains(FilterVideoType.SD)) {
false
} else {
null
}
},
is3d =
videoTypes?.letNotEmpty {
videoTypes.contains(FilterVideoType.THREE_D).takeIf { it }
},
videoTypes =
videoTypes?.letNotEmpty {
it.mapNotNull {
when (it) {
FilterVideoType.FOUR_K,
FilterVideoType.HD,
FilterVideoType.SD,
FilterVideoType.THREE_D,
-> null
FilterVideoType.BLU_RAY -> VideoType.BLU_RAY
FilterVideoType.DVD -> VideoType.DVD
}
}
},
)
fun applyTo(req: GetPersonsRequest) =
req.copy(
isFavorite = favorite,
)
fun merge(filter: GetItemsFilter): GetItemsFilter =
this.copy(
favorite = favorite ?: filter.favorite,
genres = genres ?: filter.genres,
minCommunityRating = minCommunityRating ?: filter.minCommunityRating,
officialRatings = officialRatings ?: filter.officialRatings,
persons = persons ?: filter.persons,
played = played ?: filter.played,
studios = studios ?: filter.studios,
tags = tags ?: filter.tags,
includeItemTypes = includeItemTypes ?: filter.includeItemTypes,
videoTypes = videoTypes ?: filter.videoTypes,
years = years ?: filter.years,
decades = decades ?: filter.decades,
override = override,
)
}
enum class GetItemsFilterOverride {

View file

@ -2,19 +2,18 @@
package com.github.damontecres.wholphin.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Ignore
import androidx.room.Index
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Entity(
foreignKeys = [
@ -35,14 +34,9 @@ data class LibraryDisplayInfo(
val itemId: String,
val sort: ItemSortBy,
val direction: SortOrder,
@ColumnInfo(defaultValue = "{}")
val filter: GetItemsFilter,
) {
constructor(
userId: Int,
itemId: UUID,
sort: ItemSortBy,
direction: SortOrder,
) : this(userId, itemId.toServerString(), sort, direction)
@Ignore @Transient
val sortAndDirection = SortAndDirection(sort, direction)
}