mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
fix subsonic / jellyfin filters
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user