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

@ -3,9 +3,10 @@ import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
import { useGenreList } from '/@/renderer/features/genres';
interface JellyfinSongFiltersProps {
customFilters?: Partial<SongListFilter>;
@ -24,9 +25,10 @@ export const JellyfinSongFilters = ({
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
const isGenrePage = customFilters?._custom?.jellyfin?.GenreIds !== undefined;
const isGenrePage = customFilters?.genreIds !== undefined;
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
// Despite the fact that getTags returns genres, it only returns genre names.
// We prefer using IDs, hence the double query
const genreListQuery = useGenreList({
query: {
musicFolderId: filter?.musicFolderId,
@ -45,10 +47,22 @@ export const JellyfinSongFilters = ({
}));
}, [genreListQuery.data]);
const tagsQuery = useTagList({
query: {
folder: filter?.musicFolderId,
type: LibraryItem.SONG,
},
serverId,
});
const selectedGenres = useMemo(() => {
return filter?._custom?.jellyfin?.GenreIds?.split(',');
}, [filter?._custom?.jellyfin?.GenreIds]);
const selectedTags = useMemo(() => {
return filter?._custom?.jellyfin?.Tags?.split('|');
}, [filter?._custom?.jellyfin?.Tags]);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
@ -133,6 +147,25 @@ export const JellyfinSongFilters = ({
onFilterChange(updatedFilters);
}, 250);
const handleTagFilter = debounce((e: string[] | undefined) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
IncludeItemTypes: 'Audio',
Tags: e?.join('|') || undefined,
},
},
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
}, 250);
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
@ -179,6 +212,19 @@ export const JellyfinSongFilters = ({
/>
</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

@ -6,6 +6,7 @@ import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
import { useTagList } from '/@/renderer/features/tag/queries/use-tag-list';
interface NavidromeSongFiltersProps {
customFilters?: Partial<SongListFilter>;
@ -35,6 +36,13 @@ export const NavidromeSongFilters = ({
serverId,
});
const tagsQuery = useTagList({
query: {
type: LibraryItem.SONG,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
@ -57,6 +65,25 @@ export const NavidromeSongFilters = ({
onFilterChange(updatedFilters);
}, 250);
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 SongListFilter;
onFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
@ -84,6 +111,7 @@ export const NavidromeSongFilters = ({
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
year: e === '' ? undefined : (e as number),
},
},
@ -132,6 +160,25 @@ export const NavidromeSongFilters = ({
/>
)}
</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>
);
};