mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 10:03:33 +00:00
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:
parent
b0d86ee5c9
commit
e1aa8d74f3
17 changed files with 360 additions and 16 deletions
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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[]>>;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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[]>]>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
24
src/renderer/features/tag/queries/use-tag-list.ts
Normal file
24
src/renderer/features/tag/queries/use-tag-list.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue