fix subsonic / jellyfin filters

This commit is contained in:
jeffvli
2025-11-30 17:25:44 -08:00
parent c5c2b24a9d
commit 96acf759ff
8 changed files with 395 additions and 253 deletions
+1 -1
View File
@@ -413,7 +413,7 @@ export const jfApiClient = (args: {
return {
body: response?.data,
headers: response?.headers as any,
status: response.status,
status: response?.status,
};
}
throw e;
@@ -46,9 +46,41 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
};
const MAX_SUBSONIC_ITEMS = 500;
// A trick to skip ahead 10x
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
function sortAndPaginate<T>(
items: T[],
options: {
limit?: number;
sortBy?: any;
sortFn?: (items: T[], sortBy: any, sortOrder: SortOrder) => T[];
sortOrder?: SortOrder;
startIndex?: number;
},
): {
items: T[];
startIndex: number;
totalRecordCount: number;
} {
let sortedItems = items;
if (options.sortFn && options.sortBy) {
const sortOrder = options.sortOrder || SortOrder.ASC;
sortedItems = options.sortFn(items, options.sortBy, sortOrder);
}
const totalCount = sortedItems.length;
const startIndex = options.startIndex || 0;
const limit = options.limit || totalCount;
const paginatedItems = sortedItems.slice(startIndex, startIndex + limit);
return {
items: paginatedItems,
startIndex: startIndex,
totalRecordCount: totalCount,
};
}
export const SubsonicController: InternalControllerEndpoint = {
addToPlaylist: async ({ apiClientProps, body, query }) => {
const res = await ssApiClient(apiClientProps).updatePlaylist({
@@ -246,7 +278,7 @@ export const SubsonicController: InternalControllerEndpoint = {
return {
items: results,
startIndex: query.startIndex,
totalRecordCount: results?.length || 0,
totalRecordCount: artists.length,
};
},
getAlbumArtistListCount: (args) =>
@@ -346,16 +378,18 @@ export const SubsonicController: InternalControllerEndpoint = {
throw new Error('Failed to get album list');
}
const results =
const allResults =
res.body.starred?.album?.map((album) =>
ssNormalize.album(album, apiClientProps.server),
) || [];
return {
items: sortAlbumList(results, query.sortBy, query.sortOrder),
startIndex: 0,
totalRecordCount: res.body.starred?.album?.length || 0,
};
return sortAndPaginate(allResults, {
limit: query.limit,
sortBy: query.sortBy,
sortFn: sortAlbumList,
sortOrder: query.sortOrder,
startIndex: query.startIndex,
});
}
if (query.genreIds?.length) {
@@ -592,15 +626,13 @@ export const SubsonicController: InternalControllerEndpoint = {
results = searchResults;
}
if (query.sortBy) {
results = sortAlbumArtistList(results, query.sortBy, query.sortOrder);
}
return {
items: results,
return sortAndPaginate(results, {
limit: query.limit,
sortBy: query.sortBy,
sortFn: query.sortBy ? sortAlbumArtistList : undefined,
sortOrder: query.sortOrder,
startIndex: query.startIndex,
totalRecordCount: results?.length || 0,
};
});
},
getArtistListCount: async (args) =>
SubsonicController.getArtistList({
@@ -647,11 +679,10 @@ export const SubsonicController: InternalControllerEndpoint = {
const genres = results.map((genre) => ssNormalize.genre(genre, apiClientProps.server));
return {
items: genres,
startIndex: 0,
totalRecordCount: genres.length,
};
return sortAndPaginate(genres, {
limit: query.limit,
startIndex: query.startIndex,
});
},
getMusicFolderList: async (args) => {
const { apiClientProps } = args;
@@ -728,11 +759,14 @@ export const SubsonicController: InternalControllerEndpoint = {
break;
}
return {
items: results.map((playlist) => ssNormalize.playlist(playlist, apiClientProps.server)),
startIndex: 0,
totalRecordCount: results.length,
};
const playlists = results.map((playlist) =>
ssNormalize.playlist(playlist, apiClientProps.server),
);
return sortAndPaginate(playlists, {
limit: query.limit,
startIndex: query.startIndex,
});
},
getPlaylistListCount: async ({ apiClientProps, query }) => {
const res = await ssApiClient(apiClientProps).getPlaylists({});
@@ -792,11 +826,14 @@ export const SubsonicController: InternalControllerEndpoint = {
}
const results = res.body.randomSongs?.song || [];
const normalizedResults = results.map((song) =>
ssNormalize.song(song, apiClientProps.server),
);
return {
items: results.map((song) => ssNormalize.song(song, apiClientProps.server)),
items: normalizedResults,
startIndex: 0,
totalRecordCount: res.body.randomSongs?.song?.length || 0,
totalRecordCount: normalizedResults.length,
};
},
getRoles: async (args) => {
@@ -965,16 +1002,18 @@ export const SubsonicController: InternalControllerEndpoint = {
throw new Error('Failed to get song list');
}
const results =
const allResults =
(res.body.starred?.song || []).map((song) =>
ssNormalize.song(song, apiClientProps.server),
) || [];
return {
items: sortSongList(results, query.sortBy, query.sortOrder),
startIndex: 0,
totalRecordCount: (res.body.starred?.song || []).length || 0,
};
return sortAndPaginate(allResults, {
limit: query.limit,
sortBy: query.sortBy,
sortFn: sortSongList,
sortOrder: query.sortOrder,
startIndex: query.startIndex,
});
}
const artistIds = query.albumArtistIds || query.artistIds;
@@ -1041,7 +1080,7 @@ export const SubsonicController: InternalControllerEndpoint = {
}
return {
items: results.map((song) => ssNormalize.song(song, apiClientProps.server)),
items: results.map((song) => ssNormalize.song(song, apiClientProps.server)) || [],
startIndex: 0,
totalRecordCount: results.length,
};
@@ -1,5 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,13 +7,12 @@ import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { AlbumListFilter } from '/@/renderer/store';
import { AlbumListFilter, useCurrentServerId } from '/@/renderer/store';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import {
AlbumArtistListSort,
@@ -27,14 +25,11 @@ interface JellyfinAlbumFiltersProps {
customFilters?: Partial<AlbumListFilter>;
disableArtistFilter?: boolean;
onFilterChange: (filters: AlbumListFilter) => void;
serverId: string;
}
export const JellyfinAlbumFilters = ({
disableArtistFilter,
serverId,
}: JellyfinAlbumFiltersProps) => {
export const JellyfinAlbumFilters = ({ disableArtistFilter }: JellyfinAlbumFiltersProps) => {
const { t } = useTranslation();
const serverId = useCurrentServerId();
const {
query,
@@ -110,21 +105,50 @@ export const JellyfinAlbumFilters = ({
setCompilation,
]);
const handleMinYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const year = e === '' ? undefined : (e as number);
setMinYear(year ?? null);
}, 500);
const handleMinYearFilter = useMemo(
() => (e: number | string) => {
// Handle empty string, null, undefined, or invalid numbers as clearing
if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
setMinYear(null);
return;
}
const handleMaxYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
const year = e === '' ? undefined : (e as number);
setMaxYear(year ?? null);
}, 500);
const year = typeof e === 'number' ? e : Number(e);
// If it's a valid number within range, set it; otherwise clear
if (!isNaN(year) && isFinite(year) && year >= 1700 && year <= 2300) {
setMinYear(year);
} else {
setMinYear(null);
}
},
[setMinYear],
);
const handleGenresFilter = debounce((e: string[] | undefined) => {
setGenreId(e ?? null);
}, 250);
const handleMaxYearFilter = useMemo(
() => (e: number | string) => {
// Handle empty string, null, undefined, or invalid numbers as clearing
if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
setMaxYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
// If it's a valid number within range, set it; otherwise clear
if (!isNaN(year) && isFinite(year) && year >= 1700 && year <= 2300) {
setMaxYear(year);
} else {
setMaxYear(null);
}
},
[setMaxYear],
);
const handleGenresFilter = useMemo(
() => (e: string[] | undefined) => {
setGenreId(e && e.length > 0 ? e : null);
},
[setGenreId],
);
const albumArtistListQuery = useQuery(
artistsQueries.albumArtistList({
@@ -154,24 +178,46 @@ export const JellyfinAlbumFilters = ({
setAlbumArtist(e ?? null);
};
const handleTagFilter = debounce((e: string[] | undefined) => {
setCustom((prev) => ({
...prev,
[e?.join('|') || '']: e?.join('|') || undefined,
}));
}, 250);
const handleTagFilter = useMemo(
() => (e: string[] | undefined) => {
setCustom((prev) => {
if (!prev) {
return e && e.length > 0 ? { [e.join('|')]: e.join('|') } : null;
}
if (!e || e.length === 0) {
// Remove all tag-related properties (they use '|' joined keys)
const rest = Object.fromEntries(
Object.entries(prev).filter(([key]) => !key.includes('|')),
);
return Object.keys(rest).length === 0 ? null : rest;
}
// Remove old tag entries and add new one
const rest = Object.fromEntries(
Object.entries(prev).filter(([key]) => !key.includes('|')),
);
const tagKey = e.join('|');
return {
...rest,
[tagKey]: tagKey,
};
});
},
[setCustom],
);
return (
<Stack p="0.8rem">
{yesNoFilter.map((filter) => (
<Group justify="space-between" key={`jf-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<YesNoSelect
onChange={filter.onChange}
size="xs"
value={filter.value ?? undefined}
/>
</Group>
<YesNoSelect
key={`jf-filter-${filter.label}`}
label={filter.label}
onChange={filter.onChange}
value={filter.value ?? undefined}
/>
))}
<Divider my="0.5rem" />
<Group grow>
@@ -181,7 +227,7 @@ export const JellyfinAlbumFilters = ({
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
onChange={(e) => handleMinYearFilter(e)}
onBlur={(e) => handleMinYearFilter(e.currentTarget.value)}
required={!!query.minYear}
/>
<NumberInput
@@ -190,49 +236,40 @@ export const JellyfinAlbumFilters = ({
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
onChange={(e) => handleMaxYearFilter(e)}
onBlur={(e) => handleMaxYearFilter(e.currentTarget.value)}
required={!!query.minYear}
/>
</Group>
<Group grow>
<MultiSelectWithInvalidData
clearable
data={genreList}
defaultValue={query.genreId ?? undefined}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={handleGenresFilter}
searchable
/>
</Group>
<MultiSelectWithInvalidData
clearable
data={genreList}
defaultValue={query.genreIds ?? undefined}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={(e) => handleGenresFilter(e)}
searchable
/>
<Group grow>
<MultiSelectWithInvalidData
clearable
data={selectableAlbumArtists}
defaultValue={query.artistIds ?? undefined}
disabled={disableArtistFilter}
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
limit={300}
onChange={handleAlbumArtistFilter}
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchable
/>
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
<MultiSelectWithInvalidData
clearable
data={selectableAlbumArtists}
defaultValue={query.artistIds ?? undefined}
disabled={disableArtistFilter}
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
limit={300}
onChange={handleAlbumArtistFilter}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
data={tagsQuery.data.boolTags}
defaultValue={query._custom?.[tagsQuery.data.boolTags.join('|')] ?? undefined}
label={t('common.tags', { postProcess: 'sentenceCase' })}
onChange={handleTagFilter}
searchable
width={250}
/>
</Group>
{tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
<Group grow>
<MultiSelectWithInvalidData
clearable
data={tagsQuery.data.boolTags}
defaultValue={
query._custom?.[tagsQuery.data.boolTags.join('|')] ?? undefined
}
label={t('common.tags', { postProcess: 'sentenceCase' })}
onChange={handleTagFilter}
searchable
width={250}
/>
</Group>
)}
</Stack>
);
@@ -1,14 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { parseAsArrayOf, parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs';
import { ChangeEvent, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
import { useAlbumListFilters } from '/@/renderer/features/albums/hooks/use-album-list-filters';
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { genresQueries } from '/@/renderer/features/genres/api/genres-api';
import { FILTER_KEYS } from '/@/renderer/features/shared/utils';
import { AlbumListFilter } from '/@/renderer/store';
import { useCurrentServerId } from '/@/renderer/store';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
@@ -21,29 +19,15 @@ import { AlbumArtistListSort, GenreListSort, SortOrder } from '/@/shared/types/d
interface SubsonicAlbumFiltersProps {
disableArtistFilter?: boolean;
onFilterChange: (filters: AlbumListFilter) => void;
serverId: string;
}
export const SubsonicAlbumFilters = ({
disableArtistFilter,
onFilterChange,
serverId,
}: SubsonicAlbumFiltersProps) => {
export const SubsonicAlbumFilters = ({ disableArtistFilter }: SubsonicAlbumFiltersProps) => {
const { t } = useTranslation();
const [favorite, setFavorite] = useQueryState(FILTER_KEYS.ALBUM.FAVORITE, parseAsBoolean);
const serverId = useCurrentServerId();
const [minYear, setMinYear] = useQueryState(FILTER_KEYS.ALBUM.MIN_YEAR, parseAsInteger);
const [maxYear, setMaxYear] = useQueryState(FILTER_KEYS.ALBUM.MAX_YEAR, parseAsInteger);
const [genreId, setGenreId] = useQueryState(FILTER_KEYS.ALBUM.GENRE_ID, parseAsString);
const [artistIds, setArtistIds] = useQueryState(
FILTER_KEYS.ALBUM.ARTIST_IDS,
parseAsArrayOf(parseAsString),
);
const { query, setAlbumArtist, setFavorite, setGenreId, setMaxYear, setMinYear } =
useAlbumListFilters();
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
@@ -73,13 +57,12 @@ export const SubsonicAlbumFilters = ({
}));
}, [items]);
const handleAlbumArtistFilter = (e: null | string[]) => {
setArtistIds(e ?? null);
const updatedFilters: Partial<AlbumListFilter> = {
artistIds: e?.length ? e : undefined,
};
onFilterChange(updatedFilters as AlbumListFilter);
};
const handleAlbumArtistFilter = useMemo(
() => (e: null | string[]) => {
setAlbumArtist(e ?? null);
},
[setAlbumArtist],
);
const genreListQuery = useQuery(
genresQueries.list({
@@ -104,43 +87,64 @@ export const SubsonicAlbumFilters = ({
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: null | string) => {
setGenreId(e ?? null);
const updatedFilters: Partial<AlbumListFilter> = {
genreIds: e ? [e] : undefined,
};
onFilterChange(updatedFilters as AlbumListFilter);
}, 250);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const favoriteValue = e.target.checked ? true : undefined;
setFavorite(favoriteValue ?? null);
const updatedFilters: Partial<AlbumListFilter> = {
favorite: favoriteValue,
};
onFilterChange(updatedFilters as AlbumListFilter);
},
value: favorite,
const handleGenresFilter = useMemo(
() => (e: null | string) => {
setGenreId(e ? [e] : null);
},
];
[setGenreId],
);
const handleYearFilter = debounce((e: number | string, type: 'max' | 'min') => {
const year = e ? Number(e) : undefined;
const toggleFilters = useMemo(
() => [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const favoriteValue = e.target.checked ? true : undefined;
setFavorite(favoriteValue ?? null);
},
value: query.favorite,
},
],
[t, query.favorite, setFavorite],
);
if (type === 'min') {
setMinYear(year ?? null);
} else {
setMaxYear(year ?? null);
}
const handleMinYearFilter = useMemo(
() => (e: number | string) => {
// Handle empty string, null, undefined, or invalid numbers as clearing
if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
setMinYear(null);
return;
}
const updatedFilters: Partial<AlbumListFilter> = {
[type === 'min' ? 'minYear' : 'maxYear']: year,
};
onFilterChange(updatedFilters as AlbumListFilter);
}, 500);
const year = typeof e === 'number' ? e : Number(e);
// If it's a valid number, set it; otherwise clear
if (!isNaN(year) && isFinite(year) && year > 0) {
setMinYear(year);
} else {
setMinYear(null);
}
},
[setMinYear],
);
const handleMaxYearFilter = useMemo(
() => (e: number | string) => {
// Handle empty string, null, undefined, or invalid numbers as clearing
if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
setMaxYear(null);
return;
}
const year = typeof e === 'number' ? e : Number(e);
// If it's a valid number, set it; otherwise clear
if (!isNaN(year) && isFinite(year) && year > 0) {
setMaxYear(year);
} else {
setMaxYear(null);
}
},
[setMaxYear],
);
return (
<Stack p="0.8rem">
@@ -153,51 +157,46 @@ export const SubsonicAlbumFilters = ({
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={minYear ?? undefined}
disabled={genreId !== null}
defaultValue={query.minYear ?? undefined}
disabled={Boolean(query.genreIds && query.genreIds.length > 0)}
hideControls={false}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e, 'min')}
onBlur={(e) => handleMinYearFilter(e.currentTarget.value)}
/>
<NumberInput
defaultValue={maxYear ?? undefined}
disabled={genreId !== null}
defaultValue={query.maxYear ?? undefined}
disabled={Boolean(query.genreIds && query.genreIds.length > 0)}
hideControls={false}
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e, 'max')}
/>
</Group>
<Group grow>
<Select
clearable
data={genreList}
defaultValue={genreId ?? undefined}
disabled={Boolean(minYear || maxYear)}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={handleGenresFilter}
searchable
/>
</Group>
<Group grow>
<MultiSelectWithInvalidData
clearable
data={selectableAlbumArtists}
defaultValue={artistIds ?? undefined}
disabled={disableArtistFilter}
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
limit={300}
onChange={handleAlbumArtistFilter}
onSearchChange={setAlbumArtistSearchTerm}
placeholder="Type to search for an artist"
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchable
searchValue={albumArtistSearchTerm}
onBlur={(e) => handleMaxYearFilter(e.currentTarget.value)}
/>
</Group>
<Select
clearable
data={genreList}
defaultValue={query.genreIds?.[0] ?? undefined}
disabled={Boolean(query.minYear || query.maxYear)}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={(e) => handleGenresFilter(e)}
searchable
/>
<MultiSelectWithInvalidData
clearable
data={selectableAlbumArtists}
defaultValue={query.artistIds ?? undefined}
disabled={disableArtistFilter}
label={t('entity.artist', { count: 2, postProcess: 'sentenceCase' })}
limit={300}
onChange={handleAlbumArtistFilter}
onSearchChange={setAlbumArtistSearchTerm}
rightSection={albumArtistListQuery.isFetching ? <SpinnerIcon /> : undefined}
searchable
searchValue={albumArtistSearchTerm}
/>
</Stack>
);
};
@@ -14,7 +14,7 @@ import {
export const genresQueries = {
list: (args: QueryHookArgs<GenreListQuery>) => {
return queryOptions({
gcTime: 1000 * 5,
gcTime: 1000 * 60 * 60,
queryFn: ({ signal }) => {
return api.controller.getGenreList({
apiClientProps: { serverId: args.serverId, signal },
@@ -22,6 +22,7 @@ export const genresQueries = {
});
},
queryKey: queryKeys.genres.list(args.serverId, args.query),
staleTime: 1000 * 60 * 60,
...args.options,
});
},
@@ -38,6 +38,7 @@ export const sharedQueries = {
});
},
queryKey: queryKeys.tags.list(args.serverId || '', args.query.type),
staleTime: 1000 * 60,
...args.options,
});
},
@@ -1,5 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -7,7 +6,7 @@ import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-i
import { useGenreList } from '/@/renderer/features/genres/api/genres-api';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters';
import { SongListFilter, useCurrentServer } from '/@/renderer/store';
import { SongListFilter, useCurrentServerId } from '/@/renderer/store';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
@@ -21,7 +20,7 @@ interface JellyfinSongFiltersProps {
}
export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps) => {
const server = useCurrentServer();
const serverId = useCurrentServerId();
const { t } = useTranslation();
const { query, setCustom, setFavorite, setMaxYear, setMinYear } = useSongListFilters();
@@ -44,7 +43,7 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
query: {
type: LibraryItem.SONG,
},
serverId: server.id,
serverId,
}),
);
@@ -66,33 +65,91 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
},
];
const handleMinYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
setMinYear(e === '' ? null : (e as number));
}, 500);
const handleMinYearFilter = useMemo(
() => (e: number | string) => {
// Handle empty string, null, undefined, or invalid numbers as clearing
if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
setMinYear(null);
return;
}
const handleMaxYearFilter = debounce((e: number | string) => {
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
setMaxYear(e === '' ? null : (e as number));
}, 500);
const year = typeof e === 'number' ? e : Number(e);
// If it's a valid number within range, set it; otherwise clear
if (!isNaN(year) && isFinite(year) && year >= 1700 && year <= 2300) {
setMinYear(year);
} else {
setMinYear(null);
}
},
[setMinYear],
);
const handleGenresFilter = debounce((e: string[] | undefined) => {
setCustom((prev) => ({
...prev,
GenreIds: e?.join(',') || undefined,
IncludeItemTypes: 'Audio',
...prev?.jellyfin,
}));
}, 250);
const handleMaxYearFilter = useMemo(
() => (e: number | string) => {
// Handle empty string, null, undefined, or invalid numbers as clearing
if (e === '' || e === null || e === undefined || isNaN(Number(e))) {
setMaxYear(null);
return;
}
const handleTagFilter = debounce((e: string[] | undefined) => {
setCustom((prev) => ({
...prev,
IncludeItemTypes: 'Audio',
Tags: e?.join('|') || undefined,
...prev?.jellyfin,
}));
}, 250);
const year = typeof e === 'number' ? e : Number(e);
// If it's a valid number within range, set it; otherwise clear
if (!isNaN(year) && isFinite(year) && year >= 1700 && year <= 2300) {
setMaxYear(year);
} else {
setMaxYear(null);
}
},
[setMaxYear],
);
const handleGenresFilter = useMemo(
() => (e: string[] | undefined) => {
setCustom((prev) => {
if (!e || e.length === 0) {
// Remove GenreIds and IncludeItemTypes if genres are cleared
const rest = { ...prev };
delete rest.GenreIds;
delete rest.IncludeItemTypes;
// Keep jellyfin-specific properties
return Object.keys(rest).length === 0 ? null : rest;
}
return {
...prev,
GenreIds: e.join(','),
IncludeItemTypes: 'Audio',
...prev?.jellyfin,
};
});
},
[setCustom],
);
const handleTagFilter = useMemo(
() => (e: string[] | undefined) => {
setCustom((prev) => {
if (!e || e.length === 0) {
// Remove Tags if cleared
const rest = { ...prev };
delete rest.Tags;
// Keep IncludeItemTypes and jellyfin-specific properties
if (rest.IncludeItemTypes) {
return rest;
}
return Object.keys(rest).length === 0 ? null : rest;
}
return {
...prev,
IncludeItemTypes: 'Audio',
Tags: e.join('|'),
...prev?.jellyfin,
};
});
},
[setCustom],
);
return (
<Stack p="0.8rem">
@@ -105,19 +162,21 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={query.minYear}
defaultValue={query.minYear ?? undefined}
hideControls={false}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
onChange={handleMinYearFilter}
onBlur={(e) => handleMinYearFilter(e.currentTarget.value)}
required={!!query.minYear}
/>
<NumberInput
defaultValue={query.maxYear}
defaultValue={query.maxYear ?? undefined}
hideControls={false}
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
onChange={handleMaxYearFilter}
onBlur={(e) => handleMaxYearFilter(e.currentTarget.value)}
required={!!query.minYear}
/>
</Group>
@@ -128,7 +187,7 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
data={genreList}
defaultValue={selectedGenres}
label={t('entity.genre', { count: 1, postProcess: 'sentenceCase' })}
onChange={handleGenresFilter}
onChange={(e) => handleGenresFilter(e)}
searchable
width={250}
/>
@@ -141,7 +200,7 @@ export const JellyfinSongFilters = ({ customFilters }: JellyfinSongFiltersProps)
data={tagsQuery.data.boolTags}
defaultValue={selectedTags}
label={t('common.tags', { postProcess: 'sentenceCase' })}
onChange={handleTagFilter}
onChange={(e) => handleTagFilter(e)}
searchable
width={250}
/>
@@ -1,5 +1,4 @@
import debounce from 'lodash/debounce';
import { useMemo } from 'react';
import { ChangeEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@@ -9,8 +8,8 @@ import { SongListFilter } from '/@/renderer/store';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
interface SubsonicSongFiltersProps {
customFilters?: Partial<SongListFilter>;
@@ -32,26 +31,33 @@ export const SubsonicSongFilters = ({ customFilters }: SubsonicSongFiltersProps)
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: null | string) => {
setGenreId(e ? [e] : null);
}, 250);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (favorite: boolean | undefined) => {
setFavorite(favorite ?? null);
},
value: query.favorite,
const handleGenresFilter = useMemo(
() => (e: null | string) => {
setGenreId(e ? [e] : null);
},
];
[setGenreId],
);
const toggleFilters = useMemo(
() => [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const favoriteValue = e.target.checked ? true : undefined;
setFavorite(favoriteValue ?? null);
},
value: query.favorite,
},
],
[t, query.favorite, setFavorite],
);
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group justify="space-between" key={`ss-filter-${filter.label}`}>
<Text>{filter.label}</Text>
<YesNoSelect onChange={filter.onChange} size="xs" value={filter.value} />
<Switch checked={filter.value ?? false} onChange={filter.onChange} />
</Group>
))}
<Divider my="0.5rem" />