Tag filter support

- Jellyfin: Uses `/items/filters` to get list of boolean tags. Notably, does not use this same filter for genres. Separate filter for song/album
- Navidrome: Uses `/api/tags`, which appears to be album-level as multiple independent selects. Same filter for song/album
This commit is contained in:
Kendall Garner 2025-05-18 09:23:52 -07:00
parent b0d86ee5c9
commit e1aa8d74f3
No known key found for this signature in database
GPG key ID: 9355F387FE765C94
17 changed files with 360 additions and 16 deletions

View file

@ -14,6 +14,7 @@ import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { useGenreList } from '/@/renderer/features/genres';
import { AlbumListFilter, useListStoreActions } from '/@/renderer/store';
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
interface JellyfinAlbumFiltersProps {
customFilters?: Partial<AlbumListFilter>;
@ -53,6 +54,18 @@ export const JellyfinAlbumFilters = ({
}));
}, [genreListQuery.data]);
const tagsQuery = useTagList({
query: {
folder: filter?.musicFolderId,
type: LibraryItem.SONG,
},
serverId,
});
const selectedTags = useMemo(() => {
return filter?._custom?.jellyfin?.Tags?.split('|');
}, [filter?._custom?.jellyfin?.Tags]);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
@ -150,6 +163,24 @@ export const JellyfinAlbumFilters = ({
onFilterChange(updatedFilters);
};
const handleTagFilter = debounce((e: string[] | undefined) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
Tags: e?.join('|') || undefined,
},
},
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
}, 250);
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
@ -213,6 +244,19 @@ export const JellyfinAlbumFilters = ({
onSearchChange={setAlbumArtistSearchTerm}
/>
</Group>
{tagsQuery.data?.boolTags?.length && (
<Group grow>
<MultiSelect
clearable
searchable
data={tagsQuery.data.boolTags}
defaultValue={selectedTags}
label={t('common.tags', { postProcess: 'sentenceCase' })}
width={250}
onChange={handleTagFilter}
/>
</Group>
)}
</Stack>
);
};

View file

@ -13,6 +13,7 @@ import {
SortOrder,
} from '/@/renderer/api/types';
import { useTranslation } from 'react-i18next';
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
interface NavidromeAlbumFiltersProps {
customFilters?: Partial<AlbumListFilter>;
@ -63,6 +64,13 @@ export const NavidromeAlbumFilters = ({
onFilterChange(updatedFilters);
}, 250);
const tagsQuery = useTagList({
query: {
type: LibraryItem.SONG,
},
serverId,
});
const toggleFilters = [
{
label: t('filter.isRated', { postProcess: 'sentenceCase' }),
@ -200,6 +208,25 @@ export const NavidromeAlbumFilters = ({
onFilterChange(updatedFilters);
};
const handleTagFilter = debounce((tag: string, e: string | null) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
[tag]: e || undefined,
},
},
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
}, 250);
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
@ -248,6 +275,25 @@ export const NavidromeAlbumFilters = ({
onSearchChange={setAlbumArtistSearchTerm}
/>
</Group>
{tagsQuery.data?.enumTags?.length &&
tagsQuery.data.enumTags.map((tag) => (
<Group
key={tag.name}
grow
>
<Select
clearable
searchable
data={tag.options}
defaultValue={
filter._custom?.navidrome?.[tag.name] as string | undefined
}
label={tag.name}
width={150}
onChange={(value) => handleTagFilter(tag.name, value)}
/>
</Group>
))}
</Stack>
);
};