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

@ -93,6 +93,9 @@ export const controller: GeneralController = {
getAlbumDetail(args) {
return apiController('getAlbumDetail', args.apiClientProps.server?.type)?.(args);
},
getAlbumInfo(args) {
return apiController('getAlbumInfo', args.apiClientProps.server?.type)?.(args);
},
getAlbumList(args) {
return apiController('getAlbumList', args.apiClientProps.server?.type)?.(args);
},
@ -117,9 +120,6 @@ export const controller: GeneralController = {
getMusicFolderList(args) {
return apiController('getMusicFolderList', args.apiClientProps.server?.type)?.(args);
},
getAlbumInfo(args) {
return apiController('getAlbumInfo', args.apiClientProps.server?.type)?.(args);
},
getPlaylistDetail(args) {
return apiController('getPlaylistDetail', args.apiClientProps.server?.type)?.(args);
},
@ -156,6 +156,9 @@ export const controller: GeneralController = {
getStructuredLyrics(args) {
return apiController('getStructuredLyrics', args.apiClientProps.server?.type)?.(args);
},
getTags(args) {
return apiController('getTags', args.apiClientProps.server?.type)?.(args);
},
getTopSongs(args) {
return apiController('getTopSongs', args.apiClientProps.server?.type)?.(args);
},

View file

@ -7,6 +7,7 @@ export enum ServerFeature {
PLAYLISTS_SMART = 'playlistsSmart',
PUBLIC_PLAYLIST = 'publicPlaylist',
SHARING_ALBUM_SONG = 'sharingAlbumSong',
TAGS = 'tags',
}
export type ServerFeatures = Partial<Record<ServerFeature, boolean>>;
export type ServerFeatures = Partial<Record<ServerFeature, number[]>>;

View file

@ -104,6 +104,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getFilterList: {
method: 'GET',
path: 'items/filters',
query: jfType._parameters.filterList,
responses: {
200: jfType._response.filters,
400: jfType._response.error,
},
},
getGenreList: {
method: 'GET',
path: 'genres',

View file

@ -8,6 +8,7 @@ import {
Song,
Played,
ControllerEndpoint,
LibraryItem,
} from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize';
@ -15,7 +16,7 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { z } from 'zod';
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
import { ServerFeature } from '/@/renderer/api/features-types';
import { VersionInfo, getFeatures } from '/@/renderer/api/utils';
import { VersionInfo, getFeatures, hasFeature } from '/@/renderer/api/utils';
import chunk from 'lodash/chunk';
const formatCommaDelimitedString = (value: string[]) => {
@ -35,6 +36,7 @@ const VERSION_INFO: VersionInfo = [
[ServerFeature.PUBLIC_PLAYLIST]: [1],
},
],
['10.0.0', { [ServerFeature.TAGS]: [1] }],
];
export const JellyfinController: ControllerEndpoint = {
@ -803,6 +805,31 @@ export const JellyfinController: ControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getTags: async (args) => {
const { apiClientProps, query } = args;
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
return { boolTags: undefined, enumTags: undefined };
}
const res = await jfApiClient(apiClientProps).getFilterList({
query: {
IncludeItemTypes: query.type === LibraryItem.SONG ? 'Audio' : 'MusicAlbum',
ParentId: query.folder,
UserId: apiClientProps.server?.userId ?? '',
},
});
if (res.status !== 200) {
throw new Error('failed to get tags');
}
return {
boolTags: res.body.Tags?.sort((a, b) =>
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()),
),
};
},
getTopSongs: async (args) => {
const { apiClientProps, query } = args;

View file

@ -697,6 +697,18 @@ export enum JellyfinExtensions {
const moveItem = z.null();
const filterListParameters = z.object({
IncludeItemTypes: z.string().optional(),
ParentId: z.string().optional(),
UserId: z.string().optional(),
});
const filters = z.object({
Genres: z.string().array().optional(),
Tags: z.string().array().optional(),
Years: z.number().array().optional(),
});
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,
@ -718,6 +730,7 @@ export const jfType = {
createPlaylist: createPlaylistParameters,
deletePlaylist: deletePlaylistParameters,
favorite: favoriteParameters,
filterList: filterListParameters,
genreList: genreListParameters,
musicFolderList: musicFolderListParameters,
playlistDetail: playlistDetailParameters,
@ -742,6 +755,7 @@ export const jfType = {
deletePlaylist,
error,
favorite,
filters,
genre,
genreList,
lyrics,

View file

@ -138,6 +138,14 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
getTags: {
method: 'GET',
path: 'tag',
responses: {
200: resultWithHeaders(ndType._response.tags),
500: resultWithHeaders(ndType._response.error),
},
},
getUserList: {
method: 'GET',
path: 'user',

View file

@ -46,6 +46,8 @@ const NAVIDROME_ROLES: Array<string | { label: string; value: string }> = [
'remixer',
];
const EXCLUDED_TAGS = new Set<string>(['disctotal', 'genre', 'tracktotal']);
const excludeMissing = (server: ServerListItem | null) => {
if (hasFeature(server, ServerFeature.BFR)) {
return { missing: false };
@ -484,11 +486,12 @@ export const NavidromeController: ControllerEndpoint = {
}
const features: ServerFeatures = {
bfr: !!navidromeFeatures[ServerFeature.BFR],
lyricsMultipleStructured: !!navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
playlistsSmart: !!navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
publicPlaylist: true,
sharingAlbumSong: !!navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
bfr: navidromeFeatures[ServerFeature.BFR],
lyricsMultipleStructured: navidromeFeatures[SubsonicExtensions.SONG_LYRICS],
playlistsSmart: navidromeFeatures[ServerFeature.PLAYLISTS_SMART],
publicPlaylist: [1],
sharingAlbumSong: navidromeFeatures[ServerFeature.SHARING_ALBUM_SONG],
tags: navidromeFeatures[ServerFeature.BFR],
};
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion! };
@ -597,6 +600,45 @@ export const NavidromeController: ControllerEndpoint = {
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getStructuredLyrics: SubsonicController.getStructuredLyrics,
getTags: async (args) => {
const { apiClientProps } = args;
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
return { boolTags: undefined, enumTags: undefined };
}
const res = await ndApiClient(apiClientProps).getTags();
if (res.status !== 200) {
throw new Error('failed to get tags');
}
const tagsToValues = new Map<string, string[]>();
for (const tag of res.body.data) {
if (!EXCLUDED_TAGS.has(tag.tagName)) {
if (tagsToValues.has(tag.tagName)) {
tagsToValues.get(tag.tagName)!.push(tag.tagValue);
} else {
tagsToValues.set(tag.tagName, [tag.tagValue]);
}
}
}
return {
boolTags: undefined,
enumTags: Array.from(tagsToValues)
.map((data) => ({
name: data[0],
options: data[1].sort((a, b) =>
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()),
),
}))
.sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
};
},
getTopSongs: SubsonicController.getTopSongs,
getTranscodingUrl: SubsonicController.getTranscodingUrl,
getUserList: async (args) => {

View file

@ -338,6 +338,16 @@ const moveItemParameters = z.object({
const moveItem = z.null();
const tag = z.object({
albumCount: z.number().optional(),
id: z.string(),
songCount: z.number().optional(),
tagName: z.string(),
tagValue: z.string(),
});
const tags = z.array(tag);
export const ndType = {
_enum: {
albumArtistList: NDAlbumArtistListSort,
@ -383,6 +393,7 @@ export const ndType = {
shareItem,
song,
songList,
tags,
updatePlaylist,
user,
userList,

View file

@ -294,6 +294,9 @@ export const queryKeys: Record<
return [serverId, 'song', 'similar'] as const;
},
},
tags: {
list: (serverId: string, type: string) => [serverId, 'tags', type] as const,
},
users: {
list: (serverId: string, query?: UserListQuery) => {
if (query) return [serverId, 'users', 'list', query] as const;

View file

@ -529,7 +529,6 @@ export const SubsonicController: ControllerEndpoint = {
}
let artists = (res.body.artists?.index || []).flatMap((index) => index.artist);
console.log(artists.length);
if (query.role) {
artists = artists.filter(
(artist) => !artist.roles || artist.roles.includes(query.role!),
@ -811,7 +810,7 @@ export const SubsonicController: ControllerEndpoint = {
}
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
features.lyricsMultipleStructured = true;
features.lyricsMultipleStructured = [1];
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };

View file

@ -1239,6 +1239,25 @@ export type TranscodingArgs = {
query: TranscodingQuery;
} & BaseEndpointArgs;
export type TagQuery = {
folder?: string;
type: LibraryItem.ALBUM | LibraryItem.SONG;
};
export type TagArgs = {
query: TagQuery;
} & BaseEndpointArgs;
export type Tag = {
name: string;
options: string[];
};
export type TagResponses = {
boolTags?: string[];
enumTags?: Tag[];
};
export type ControllerEndpoint = {
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
authenticate: (
@ -1275,6 +1294,7 @@ export type ControllerEndpoint = {
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getSongListCount: (args: SongListArgs) => Promise<number>;
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTags?: (args: TagArgs) => Promise<TagResponses>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
getTranscodingUrl: (args: TranscodingArgs) => string;
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;

View file

@ -48,7 +48,7 @@ export const hasFeature = (server: ServerListItem | null, feature: ServerFeature
return false;
}
return server.features[feature] ?? false;
return (server.features[feature]?.length || 0) > 0;
};
export type VersionInfo = ReadonlyArray<[string, Record<string, readonly number[]>]>;

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>
);
};

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>
);
};

View file

@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { hasFeature } from '/@/renderer/api/utils';
import { ServerFeature } from '/@/renderer/api/features-types';
import { TagQuery } from '/@/renderer/api/types';
export const useTagList = (args: QueryHookArgs<TagQuery>) => {
const { query, options, serverId } = args || {};
const server = getServerById(serverId);
return useQuery({
enabled: !!server && hasFeature(server, ServerFeature.TAGS),
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getTags({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.tags.list(server?.id || ''),
staleTime: 1000 * 60,
...options,
});
};