mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
Initial work: support showing studios for jellyfin, allow pill to be clickable (#1566)
This commit is contained in:
@@ -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}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user