Initial work: support showing studios for jellyfin, allow pill to be clickable (#1566)

This commit is contained in:
Kendall Garner
2026-01-18 21:53:34 +00:00
committed by GitHub
parent cf428a14a3
commit 5c06624f8c
11 changed files with 228 additions and 315 deletions
@@ -248,6 +248,15 @@ export const contract = c.router({
404: jfType._response.error, 404: jfType._response.error,
}, },
}, },
getStudioList: {
method: 'GET',
path: 'studios',
query: jfType._parameters.studioList,
responses: {
200: jfType._response.studioList,
400: jfType._response.error,
},
},
getTopSongsList: { getTopSongsList: {
method: 'GET', method: 'GET',
path: 'users/:userId/items', path: 'users/:userId/items',
@@ -25,6 +25,7 @@ import {
songListSortMap, songListSortMap,
SortOrder, SortOrder,
sortOrderMap, sortOrderMap,
Tag,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types'; import { ServerFeature } from '/@/shared/types/features-types';
@@ -1233,12 +1234,38 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('failed to get tags'); throw new Error('failed to get tags');
} }
return { const studioRes = await jfApiClient(apiClientProps).getStudioList({
boolTags: res.body.Tags?.sort((a, b) => query: {
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()), EnableTotalRecordCount: true,
), IncludeItemTypes: query.type === LibraryItem.SONG ? 'Audio' : 'MusicAlbum',
excluded: { album: [], song: [] }, ParentId: query.folder,
}; },
});
if (studioRes.status !== 200) {
throw new Error('failed to get studios');
}
const tags: Tag[] = [];
if (res.body.Tags?.length) {
tags.push({
name: 'Tags',
options: res.body.Tags.sort((a, b) =>
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()),
).map((tag) => ({ id: tag, name: tag })),
});
}
if (studioRes.body.Items.length) {
tags.push({
name: 'Studios',
options: studioRes.body.Items.sort((a, b) =>
a.Name.toLocaleLowerCase().localeCompare(b.Name.toLocaleLowerCase()),
).map((option) => ({ id: option.Name, name: option.Name })),
});
}
return { excluded: { album: [], song: [] }, tags };
}, },
getTopSongs: async (args) => { getTopSongs: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -778,7 +778,7 @@ export const NavidromeController: InternalControllerEndpoint = {
} }
} }
const enumTags = Array.from(tagsToValues) const tags = Array.from(tagsToValues)
.map((data) => ({ .map((data) => ({
name: data[0], name: data[0],
options: data[1] options: data[1]
@@ -793,12 +793,11 @@ export const NavidromeController: InternalControllerEndpoint = {
const excludedSongTags = Array.from(EXCLUDED_SONG_TAGS.values()); const excludedSongTags = Array.from(EXCLUDED_SONG_TAGS.values());
return { return {
boolTags: undefined,
enumTags,
excluded: { excluded: {
album: excludedAlbumTags, album: excludedAlbumTags,
song: excludedSongTags, song: excludedSongTags,
}, },
tags,
}; };
}, },
getTopSongs: SubsonicController.getTopSongs, getTopSongs: SubsonicController.getTopSongs,
@@ -49,6 +49,7 @@ import {
AlbumListSort, AlbumListSort,
ExplicitStatus, ExplicitStatus,
LibraryItem, LibraryItem,
ServerType,
Song, Song,
SongListSort, SongListSort,
SortOrder, SortOrder,
@@ -152,8 +153,15 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
if (!album?.recordLabels || album.recordLabels.length === 0) return []; if (!album?.recordLabels || album.recordLabels.length === 0) return [];
return album.recordLabels.map((label) => { return album.recordLabels.map((label) => {
if (album._serverType === ServerType.SUBSONIC) {
return { id: label, label: label, url: null };
}
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
const customFilters = { recordlabel: [label] }; const customFilters =
album._serverType === ServerType.JELLYFIN
? { Studios: [label] }
: { recordlabel: [label] };
const paramsWithCustom = setJsonSearchParam( const paramsWithCustom = setJsonSearchParam(
searchParams, searchParams,
FILTER_KEYS.ALBUM._CUSTOM, FILTER_KEYS.ALBUM._CUSTOM,
@@ -183,15 +191,21 @@ const AlbumMetadataTags = ({ album }: AlbumMetadataTagsProps) => {
</Text> </Text>
<div className={styles['pill-group-wrapper']}> <div className={styles['pill-group-wrapper']}>
<Pill.Group> <Pill.Group>
{recordLabels.map((recordLabel) => ( {recordLabels.map((recordLabel) =>
<PillLink recordLabel.url ? (
key={`recordlabel-${recordLabel.id}`} <PillLink
size="md" key={`recordlabel-${recordLabel.id}`}
to={recordLabel.url} size="md"
> to={recordLabel.url}
{recordLabel.label} >
</PillLink> {recordLabel.label}
))} </PillLink>
) : (
<Pill key={`recordlabel-${recordLabel.id}`} size="md">
{recordLabel.label}
</Pill>
),
)}
</Pill.Group> </Pill.Group>
</div> </div>
</Stack> </Stack>
@@ -3,16 +3,15 @@ import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { genresQueries } from '/@/renderer/features/genres/api/genres-api'; import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { import {
ArtistMultiSelectRow, ArtistMultiSelectRow,
GenreMultiSelectRow, GenreMultiSelectRow,
} from '/@/renderer/features/shared/components/multi-select-rows'; } from '/@/renderer/features/shared/components/multi-select-rows';
import { TagFilters } from '/@/renderer/features/shared/components/tag-filter';
import { useCurrentServerId } from '/@/renderer/store'; import { useCurrentServerId } from '/@/renderer/store';
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
@@ -78,19 +77,6 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
})); }));
}, [genreListQuery.data]); }, [genreListQuery.data]);
const tagsQuery = useQuery(
sharedQueries.tagList({
options: {
gcTime: 1000 * 60 * 2,
staleTime: 1000 * 60 * 1,
},
query: {
type: LibraryItem.ALBUM,
},
serverId,
}),
);
const yesNoFilter = useMemo(() => { const yesNoFilter = useMemo(() => {
const filters = [ const filters = [
{ {
@@ -204,13 +190,6 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
[setAlbumArtist], [setAlbumArtist],
); );
const handleTagFilter = useCallback(
(e: null | string[]) => {
setCustom({ Tags: e && e.length > 0 ? e.join('|') : null });
},
[setCustom],
);
const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300); const debouncedHandleMinYearFilter = useDebouncedCallback(handleMinYearFilter, 300);
const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300); const debouncedHandleMaxYearFilter = useDebouncedCallback(handleMaxYearFilter, 300);
@@ -358,17 +337,7 @@ export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFilte
value={query.maxYear ?? undefined} value={query.maxYear ?? undefined}
/> />
</Group> </Group>
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && ( <TagFilters query={query} setCustom={setCustom} type={LibraryItem.ALBUM} />
<MultiSelectWithInvalidData
clearable
data={tagsQuery.data.boolTags}
label={t('common.tags', { postProcess: 'sentenceCase' })}
onChange={handleTagFilter}
searchable
value={query._custom?.[tagsQuery.data.boolTags.join('|')] || []}
width={250}
/>
)}
<Divider my="md" /> <Divider my="md" />
<Button fullWidth onClick={clear} variant="subtle"> <Button fullWidth onClick={clear} variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })} {t('common.reset', { postProcess: 'sentenceCase' })}
@@ -1,22 +1,19 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ChangeEvent, useCallback, useMemo } from 'react'; import { ChangeEvent, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters'; import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { import {
ArtistMultiSelectRow, ArtistMultiSelectRow,
GenreMultiSelectRow, GenreMultiSelectRow,
} from '/@/renderer/features/shared/components/multi-select-rows'; } from '/@/renderer/features/shared/components/multi-select-rows';
import { useCurrentServer, useCurrentServerId } from '/@/renderer/store'; import { TagFilters } from '/@/renderer/features/shared/components/tag-filter';
import { useCurrentServer } from '/@/renderer/store';
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
import { titleCase } from '/@/renderer/utils';
import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider'; import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
@@ -50,6 +47,7 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
query, query,
setAlbumArtist, setAlbumArtist,
setCompilation, setCompilation,
setCustom,
setFavorite, setFavorite,
setGenreId, setGenreId,
setHasRating, setHasRating,
@@ -355,8 +353,7 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
onChange={(e) => debouncedHandleYearFilter(e)} onChange={(e) => debouncedHandleYearFilter(e)}
value={query.minYear ?? undefined} value={query.minYear ?? undefined}
/> />
<Divider my="md" /> <TagFilters query={query} setCustom={setCustom} type={LibraryItem.ALBUM} />
<TagFilters />
<Divider my="md" /> <Divider my="md" />
<Button fullWidth onClick={clear} variant="subtle"> <Button fullWidth onClick={clear} variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })} {t('common.reset', { postProcess: 'sentenceCase' })}
@@ -364,111 +361,3 @@ export const NavidromeAlbumFilters = ({ disableArtistFilter }: NavidromeAlbumFil
</Stack> </Stack>
); );
}; };
interface TagFilterItemProps {
label: string;
onChange: (value: null | string[]) => void;
options: Array<{ id: string; name: string }>;
tagValue: string;
value: string | string[] | undefined;
}
const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
const selectData = useMemo(
() =>
options.map((option) => ({
label: option.name,
value: option.id,
})),
[options],
);
const currentValue = useMemo(() => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}, [value]);
const handleChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
onChange(e);
} else {
onChange(null);
}
},
[onChange],
);
return (
<MultiSelectWithInvalidData
clearable
data={selectData}
key={tagValue}
label={label}
limit={100}
onChange={handleChange}
searchable
value={currentValue}
/>
);
};
TagFilterItem.displayName = 'TagFilterItem';
const TagFilters = () => {
const { query, setCustom } = useAlbumListFilters();
const serverId = useCurrentServerId();
const tagsQuery = useSuspenseQuery(
sharedQueries.tagList({
options: {
gcTime: 1000 * 60 * 60,
staleTime: 1000 * 60 * 60,
},
query: {
type: LibraryItem.ALBUM,
},
serverId,
}),
);
const handleTagFilter = useMemo(
() => (tag: string, e: null | string[]) => {
setCustom({ [tag]: e });
},
[setCustom],
);
const tags = useMemo(() => {
const results: { label: string; options: { id: string; name: string }[]; value: string }[] =
[];
for (const tag of tagsQuery.data?.enumTags || []) {
if (!tagsQuery.data?.excluded.album.includes(tag.name)) {
results.push({
label: NDSongQueryFieldsLabelMap[tag.name] ?? titleCase(tag.name),
options: tag.options,
value: tag.name,
});
}
}
return results;
}, [tagsQuery.data]);
return (
<>
{tags.map((tag) => (
<TagFilterItem
key={tag.value}
label={tag.label}
onChange={(e) => handleTagFilter(tag.value, e)}
options={tag.options}
tagValue={tag.value}
value={query._custom?.[tag.value] as string | string[] | undefined}
/>
))}
</>
);
};
@@ -0,0 +1,124 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useCurrentServerId } from '/@/renderer/store';
import { titleCase } from '/@/renderer/utils';
import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types';
import { LibraryItem } from '/@/shared/types/domain-types';
interface TagFilterItemProps {
label: string;
onChange: (value: null | string[]) => void;
options: Array<{ id: string; name: string }>;
tagValue: string;
value: string | string[] | undefined;
}
const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
const selectData = useMemo(
() =>
options.map((option) => ({
label: option.name,
value: option.id,
})),
[options],
);
const defaultValue = useMemo(() => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}, [value]);
const handleChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
onChange(e);
} else {
onChange(null);
}
},
[onChange],
);
return (
<MultiSelectWithInvalidData
clearable
data={selectData}
defaultValue={defaultValue}
key={tagValue}
label={label}
limit={100}
onChange={handleChange}
searchable
/>
);
};
TagFilterItem.displayName = 'TagFilterItem';
interface TagFiltersProps {
query: Record<string, any | undefined>;
setCustom: (value: null | Record<string, any>) => void;
type: LibraryItem.ALBUM | LibraryItem.SONG;
}
export const TagFilters = ({ query, setCustom, type }: TagFiltersProps) => {
const serverId = useCurrentServerId();
const tagsQuery = useSuspenseQuery(
sharedQueries.tagList({
options: {
gcTime: 1000 * 60 * 60,
staleTime: 1000 * 60 * 60,
},
query: { type },
serverId,
}),
);
const handleTagFilter = useMemo(
() => (tag: string, e: null | string[]) => {
setCustom({ [tag]: e || undefined });
},
[setCustom],
);
const enumTags = useMemo(() => {
const results: { label: string; options: { id: string; name: string }[]; value: string }[] =
[];
const excluded =
type === LibraryItem.ALBUM
? tagsQuery.data?.excluded.album
: tagsQuery.data?.excluded.song;
for (const tag of tagsQuery.data?.tags || []) {
if (!excluded.includes(tag.name)) {
results.push({
label: NDSongQueryFieldsLabelMap[tag.name] ?? titleCase(tag.name),
options: tag.options,
value: tag.name,
});
}
}
return results;
}, [tagsQuery.data?.tags, tagsQuery.data?.excluded.album, tagsQuery.data?.excluded.song, type]);
return (
<>
{enumTags.map((tag) => (
<TagFilterItem
key={tag.value}
label={tag.label}
onChange={(e) => handleTagFilter(tag.value, e)}
options={tag.options}
tagValue={tag.value}
value={query._custom?.[tag.value] as string | string[] | undefined}
/>
))}
</>
);
};
@@ -1,17 +1,16 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { import {
ArtistMultiSelectRow, ArtistMultiSelectRow,
GenreMultiSelectRow, GenreMultiSelectRow,
} from '/@/renderer/features/shared/components/multi-select-rows'; } from '/@/renderer/features/shared/components/multi-select-rows';
import { TagFilters } from '/@/renderer/features/shared/components/tag-filter';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
import { useCurrentServer } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
@@ -87,25 +86,12 @@ export const JellyfinSongFilters = ({ disableArtistFilter }: JellyfinSongFilters
})); }));
}, [albumArtistListQuery.data?.items]); }, [albumArtistListQuery.data?.items]);
const tagsQuery = useQuery(
sharedQueries.tagList({
query: {
type: LibraryItem.SONG,
},
serverId,
}),
);
const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]); const selectedArtistIds = useMemo(() => query.artistIds || [], [query.artistIds]);
const selectedGenres = useMemo(() => { const selectedGenres = useMemo(() => {
return query._custom?.GenreIds?.split(',') || []; return query._custom?.GenreIds?.split(',') || [];
}, [query._custom?.GenreIds]); }, [query._custom?.GenreIds]);
const selectedTags = useMemo(() => {
return query._custom?.Tags?.split('|');
}, [query._custom?.Tags]);
const yesNoFilters = [ const yesNoFilters = [
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
@@ -181,13 +167,6 @@ export const JellyfinSongFilters = ({ disableArtistFilter }: JellyfinSongFilters
[setCustom], [setCustom],
); );
const handleTagFilter = useCallback(
(e: null | string[]) => {
setCustom({ Tags: e && e.length > 0 ? e.join('|') : null });
},
[setCustom],
);
const artistSelectMode = useAppStore((state) => state.artistSelectMode); const artistSelectMode = useAppStore((state) => state.artistSelectMode);
const genreSelectMode = useAppStore((state) => state.genreSelectMode); const genreSelectMode = useAppStore((state) => state.genreSelectMode);
const { setArtistSelectMode, setGenreSelectMode } = useAppStoreActions(); const { setArtistSelectMode, setGenreSelectMode } = useAppStoreActions();
@@ -339,16 +318,7 @@ export const JellyfinSongFilters = ({ disableArtistFilter }: JellyfinSongFilters
value={query.maxYear ?? undefined} value={query.maxYear ?? undefined}
/> />
</Group> </Group>
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && ( <TagFilters query={query} setCustom={setCustom} type={LibraryItem.SONG} />
<MultiSelectWithInvalidData
clearable
data={tagsQuery.data.boolTags}
label={t('common.tags', { postProcess: 'sentenceCase' })}
onChange={handleTagFilter}
searchable
value={selectedTags}
/>
)}
<Divider my="md" /> <Divider my="md" />
<Button fullWidth onClick={clear} variant="subtle"> <Button fullWidth onClick={clear} variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })} {t('common.reset', { postProcess: 'sentenceCase' })}
@@ -3,20 +3,17 @@ import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { getItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import { useListContext } from '/@/renderer/context/list-context'; import { useListContext } from '/@/renderer/context/list-context';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api'; import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { useGenreList } from '/@/renderer/features/genres/api/genres-api'; import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { import {
ArtistMultiSelectRow, ArtistMultiSelectRow,
GenreMultiSelectRow, GenreMultiSelectRow,
} from '/@/renderer/features/shared/components/multi-select-rows'; } from '/@/renderer/features/shared/components/multi-select-rows';
import { TagFilters } from '/@/renderer/features/shared/components/tag-filter';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
import { useCurrentServer, useCurrentServerId } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store';
import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store';
import { titleCase } from '/@/renderer/utils';
import { NDSongQueryFieldsLabelMap } from '/@/shared/api/navidrome/navidrome-types';
import { Button } from '/@/shared/components/button/button'; import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider'; import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
@@ -32,8 +29,16 @@ export const NavidromeSongFilters = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const server = useCurrentServer(); const server = useCurrentServer();
const serverId = server.id; const serverId = server.id;
const { clear, query, setArtistIds, setFavorite, setGenreId, setMaxYear, setMinYear } = const {
useSongListFilters(); clear,
query,
setArtistIds,
setCustom,
setFavorite,
setGenreId,
setMaxYear,
setMinYear,
} = useSongListFilters();
const { customFilters } = useListContext(); const { customFilters } = useListContext();
@@ -292,8 +297,7 @@ export const NavidromeSongFilters = () => {
onChange={(e) => debouncedHandleYearFilter(e)} onChange={(e) => debouncedHandleYearFilter(e)}
value={query.minYear ?? undefined} value={query.minYear ?? undefined}
/> />
<Divider my="md" /> <TagFilters query={query} setCustom={setCustom} type={LibraryItem.SONG} />
<TagFilters />
<Divider my="md" /> <Divider my="md" />
<Button fullWidth onClick={clear} variant="subtle"> <Button fullWidth onClick={clear} variant="subtle">
{t('common.reset', { postProcess: 'sentenceCase' })} {t('common.reset', { postProcess: 'sentenceCase' })}
@@ -301,105 +305,3 @@ export const NavidromeSongFilters = () => {
</Stack> </Stack>
); );
}; };
interface TagFilterItemProps {
label: string;
onChange: (value: null | string[]) => void;
options: Array<{ id: string; name: string }>;
tagValue: string;
value: string | string[] | undefined;
}
const TagFilterItem = ({ label, onChange, options, tagValue, value }: TagFilterItemProps) => {
const selectData = useMemo(
() =>
options.map((option) => ({
label: option.name,
value: option.id,
})),
[options],
);
const currentValue = useMemo(() => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}, [value]);
const handleChange = useCallback(
(e: null | string[]) => {
if (e && e.length > 0) {
onChange(e);
} else {
onChange(null);
}
},
[onChange],
);
return (
<MultiSelectWithInvalidData
clearable
data={selectData}
key={tagValue}
label={label}
limit={100}
onChange={handleChange}
searchable
value={currentValue}
/>
);
};
TagFilterItem.displayName = 'TagFilterItem';
const TagFilters = () => {
const { query, setCustom } = useSongListFilters();
const serverId = useCurrentServerId();
const tagsQuery = useSuspenseQuery(
sharedQueries.tagList({
query: { type: LibraryItem.SONG },
serverId,
}),
);
const handleTagFilter = useMemo(
() => (tag: string, e: null | string[]) => {
setCustom({ [tag]: e });
},
[setCustom],
);
const tags = useMemo(() => {
const results: { label: string; options: { id: string; name: string }[]; value: string }[] =
[];
for (const tag of tagsQuery.data?.enumTags || []) {
if (!tagsQuery.data?.excluded.song.includes(tag.name)) {
results.push({
label: NDSongQueryFieldsLabelMap[tag.name] ?? titleCase(tag.name),
options: tag.options,
value: tag.name,
});
}
}
return results;
}, [tagsQuery.data]);
return (
<>
{tags.map((tag) => (
<TagFilterItem
key={tag.value}
label={tag.label}
onChange={(e) => handleTagFilter(tag.value, e)}
options={tag.options}
tagValue={tag.value}
value={query._custom?.[tag.value] as string | string[] | undefined}
/>
))}
</>
);
};
+12
View File
@@ -830,6 +830,16 @@ const getSessions = z.array(
), ),
); );
const studioListParameters = paginationParameters.merge(
baseParameters.extend({
NameStartsWithOrGreater: z.string().optional(),
}),
);
const studioList = z.object({
Items: z.array(studio),
});
export const jfType = { export const jfType = {
_enum: { _enum: {
albumArtistList: albumArtistListSort, albumArtistList: albumArtistListSort,
@@ -866,6 +876,7 @@ export const jfType = {
similarSongs: similarSongsParameters, similarSongs: similarSongsParameters,
songDetail: songDetailParameters, songDetail: songDetailParameters,
songList: songListParameters, songList: songListParameters,
studioList: studioListParameters,
updatePlaylist: updatePlaylistParameters, updatePlaylist: updatePlaylistParameters,
}, },
_response: { _response: {
@@ -899,6 +910,7 @@ export const jfType = {
similarSongs, similarSongs,
song, song,
songList, songList,
studioList,
topSongsList, topSongsList,
updatePlaylist, updatePlaylist,
user, user,
+1 -3
View File
@@ -1616,7 +1616,6 @@ export type StructuredUnsyncedLyric = Omit<FullLyricsMetadata, 'lyrics'> & {
}; };
export type Tag = { export type Tag = {
id: string;
name: string; name: string;
options: { id: string; name: string }[]; options: { id: string; name: string }[];
}; };
@@ -1632,12 +1631,11 @@ export type TagListQuery = {
}; };
export type TagListResponse = { export type TagListResponse = {
boolTags?: string[];
enumTags?: { name: string; options: { id: string; name: string }[] }[];
excluded: { excluded: {
album: string[]; album: string[];
song: string[]; song: string[];
}; };
tags?: Tag[];
}; };
export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery }; export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery };