Add server-specific album filters
This commit is contained in:
parent
223cf469f4
commit
57c34637cf
7 changed files with 323 additions and 39 deletions
|
@ -247,7 +247,16 @@ const getAlbumDetail = async (args: AlbumDetailArgs): Promise<JFAlbumDetail> =>
|
|||
const getAlbumList = async (args: AlbumListArgs): Promise<JFAlbumList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: JFAlbumListParams = {
|
||||
const yearsGroup = [];
|
||||
if (query.jfParams?.minYear && query.jfParams?.maxYear) {
|
||||
for (let i = Number(query.jfParams.minYear); i <= Number(query.jfParams.maxYear); i += 1) {
|
||||
yearsGroup.push(String(i));
|
||||
}
|
||||
}
|
||||
|
||||
const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;
|
||||
|
||||
const searchParams: JFAlbumListParams & { maxYear?: number; minYear?: number } = {
|
||||
includeItemTypes: 'MusicAlbum',
|
||||
limit: query.limit,
|
||||
parentId: query.musicFolderId,
|
||||
|
@ -257,6 +266,9 @@ const getAlbumList = async (args: AlbumListArgs): Promise<JFAlbumList> => {
|
|||
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
startIndex: query.startIndex,
|
||||
...query.jfParams,
|
||||
maxYear: undefined,
|
||||
minYear: undefined,
|
||||
years: yearsFilter,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
|
|
|
@ -499,11 +499,16 @@ export enum JFAlbumListSort {
|
|||
}
|
||||
|
||||
export type JFAlbumListParams = {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
includeItemTypes: 'MusicAlbum';
|
||||
isFavorite?: boolean;
|
||||
searchTerm?: string;
|
||||
sortBy?: JFAlbumListSort;
|
||||
tags?: string;
|
||||
years?: string;
|
||||
} & JFBaseParams &
|
||||
JFPaginationParams;
|
||||
|
|
|
@ -215,7 +215,7 @@ const getAlbumList = async (args: AlbumListArgs): Promise<NDAlbumList> => {
|
|||
const res = await api.get('api/album', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
|
|
|
@ -287,9 +287,15 @@ export enum AlbumListSort {
|
|||
|
||||
export type AlbumListQuery = {
|
||||
jfParams?: {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
years?: string;
|
||||
isFavorite?: boolean;
|
||||
maxYear?: number; // Parses to years
|
||||
minYear?: number; // Parses to years
|
||||
tags?: string;
|
||||
};
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
|
@ -299,6 +305,7 @@ export type AlbumListQuery = {
|
|||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
name?: string;
|
||||
recently_played?: boolean;
|
||||
starred?: boolean;
|
||||
year?: number;
|
||||
};
|
||||
|
|
|
@ -3,9 +3,22 @@ import { useCallback } from 'react';
|
|||
import { Flex, Slider } from '@mantine/core';
|
||||
import debounce from 'lodash/debounce';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { RiArrowDownSLine } from 'react-icons/ri';
|
||||
import { AlbumListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { Button, DropdownMenu, PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiFilter3Line,
|
||||
RiFolder2Line,
|
||||
RiSortAsc,
|
||||
RiSortDesc,
|
||||
} from 'react-icons/ri';
|
||||
import { AlbumListSort, ServerType, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
PageHeader,
|
||||
Popover,
|
||||
SearchInput,
|
||||
TextTitle,
|
||||
} from '/@/renderer/components';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useAlbumListStore,
|
||||
|
@ -15,6 +28,8 @@ import {
|
|||
import { CardDisplayType } from '/@/renderer/types';
|
||||
import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import styled from 'styled-components';
|
||||
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
|
||||
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
|
@ -35,6 +50,7 @@ const FILTERS = {
|
|||
{ name: 'Random', value: AlbumListSort.RANDOM },
|
||||
{ name: 'Rating', value: AlbumListSort.RATING },
|
||||
{ name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
|
||||
{ name: 'Recently Played', value: AlbumListSort.RECENTLY_PLAYED },
|
||||
{ name: 'Song Count', value: AlbumListSort.SONG_COUNT },
|
||||
{ name: 'Favorited', value: AlbumListSort.FAVORITED },
|
||||
{ name: 'Year', value: AlbumListSort.YEAR },
|
||||
|
@ -68,8 +84,6 @@ export const AlbumListHeader = () => {
|
|||
)?.name) ||
|
||||
'Unknown';
|
||||
|
||||
const sortOrderLabel = ORDER.find((s) => s.value === filters.sortOrder)?.name;
|
||||
|
||||
const setSize = throttle(
|
||||
(e: number) =>
|
||||
setPage({
|
||||
|
@ -101,13 +115,9 @@ export const AlbumListHeader = () => {
|
|||
const handleSetOrder = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!e.currentTarget?.value) return;
|
||||
debounce(
|
||||
() =>
|
||||
setFilter({
|
||||
sortOrder: e.currentTarget.value as SortOrder,
|
||||
}),
|
||||
1000,
|
||||
);
|
||||
});
|
||||
},
|
||||
[setFilter],
|
||||
);
|
||||
|
@ -153,19 +163,25 @@ export const AlbumListHeader = () => {
|
|||
<HeaderItems>
|
||||
<Flex
|
||||
align="center"
|
||||
gap="sm"
|
||||
gap="md"
|
||||
justify="center"
|
||||
>
|
||||
<DropdownMenu position="bottom-end">
|
||||
<DropdownMenu position="bottom">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
pl={0}
|
||||
pr="0.5rem"
|
||||
rightIcon={<RiArrowDownSLine size={15} />}
|
||||
size="xl"
|
||||
sx={{ paddingLeft: 0, paddingRight: 0 }}
|
||||
variant="subtle"
|
||||
>
|
||||
<TextTitle
|
||||
fw="bold"
|
||||
order={2}
|
||||
>
|
||||
Albums
|
||||
</TextTitle>
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
|
@ -201,7 +217,7 @@ export const AlbumListHeader = () => {
|
|||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu position="bottom">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
|
@ -224,14 +240,18 @@ export const AlbumListHeader = () => {
|
|||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu position="bottom">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="normal"
|
||||
variant="subtle"
|
||||
>
|
||||
{sortOrderLabel}
|
||||
{filters.sortOrder === SortOrder.ASC ? (
|
||||
<RiSortAsc size={15} />
|
||||
) : (
|
||||
<RiSortDesc size={15} />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
|
@ -247,14 +267,15 @@ export const AlbumListHeader = () => {
|
|||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu position="bottom-start">
|
||||
{server?.type === ServerType.JELLYFIN && (
|
||||
<DropdownMenu position="bottom">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="normal"
|
||||
variant="subtle"
|
||||
>
|
||||
Folder
|
||||
<RiFolder2Line size={15} />
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
|
@ -270,6 +291,25 @@ export const AlbumListHeader = () => {
|
|||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Popover>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
compact
|
||||
fw="normal"
|
||||
variant="subtle"
|
||||
>
|
||||
<RiFilter3Line size={15} />
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
{server?.type === ServerType.NAVIDROME ? (
|
||||
<NavidromeAlbumFilters />
|
||||
) : (
|
||||
<JellyfinAlbumFilters />
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Flex>
|
||||
<Flex>
|
||||
<SearchInput
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
import { ChangeEvent, useMemo } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
|
||||
import { useAlbumListStore, useSetAlbumFilters } from '/@/renderer/store';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
|
||||
export const JellyfinAlbumFilters = () => {
|
||||
const { filter } = useAlbumListStore();
|
||||
const setFilters = useSetAlbumFilters();
|
||||
|
||||
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
|
||||
const genreListQuery = useGenreList(null);
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const selectedGenres = useMemo(() => {
|
||||
return filter.jfParams?.genreIds?.split(',');
|
||||
}, [filter.jfParams?.genreIds]);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: 'Is favorited',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilters({
|
||||
jfParams: { ...filter.jfParams, isFavorite: e.currentTarget.checked ? true : undefined },
|
||||
});
|
||||
},
|
||||
value: filter.jfParams?.isFavorite,
|
||||
},
|
||||
];
|
||||
|
||||
const handleMinYearFilter = debounce((e: number | undefined) => {
|
||||
if (e && (e < 1700 || e > 2300)) return;
|
||||
setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
minYear: e,
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
|
||||
const handleMaxYearFilter = debounce((e: number | undefined) => {
|
||||
if (e && (e < 1700 || e > 2300)) return;
|
||||
setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
maxYear: e,
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
|
||||
const handleGenresFilter = debounce((e: string[] | undefined) => {
|
||||
const genreFilterString = e?.join(',');
|
||||
setFilters({
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
genreIds: genreFilterString,
|
||||
},
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
checked={filter?.value || false}
|
||||
size="xs"
|
||||
onChange={filter.onChange}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group position="apart">
|
||||
<Text>Year range</Text>
|
||||
<Group>
|
||||
<NumberInput
|
||||
required
|
||||
max={2300}
|
||||
min={1700}
|
||||
size="sm"
|
||||
value={filter.jfParams?.minYear}
|
||||
width={60}
|
||||
onChange={handleMinYearFilter}
|
||||
/>
|
||||
<NumberInput
|
||||
max={2300}
|
||||
min={1700}
|
||||
size="sm"
|
||||
value={filter.jfParams?.maxYear}
|
||||
width={60}
|
||||
onChange={handleMaxYearFilter}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<Divider my="0.5rem" />
|
||||
<Stack>
|
||||
<Text>Genres</Text>
|
||||
<MultiSelect
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={selectedGenres}
|
||||
width={250}
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider my="0.5rem" />
|
||||
<Stack>
|
||||
<Text>Tags</Text>
|
||||
<MultiSelect
|
||||
disabled
|
||||
data={[]}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,91 @@
|
|||
import { ChangeEvent } from 'react';
|
||||
import { Divider, Group, Stack } from '@mantine/core';
|
||||
import { NumberInput, Switch, Text } from '/@/renderer/components';
|
||||
import { useAlbumListStore, useSetAlbumFilters } from '/@/renderer/store';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
export const NavidromeAlbumFilters = () => {
|
||||
const { filter } = useAlbumListStore();
|
||||
const setFilters = useSetAlbumFilters();
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
label: 'Is rated',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilters({
|
||||
ndParams: { ...filter.ndParams, has_rating: e.currentTarget.checked ? true : undefined },
|
||||
});
|
||||
},
|
||||
value: filter.ndParams?.has_rating,
|
||||
},
|
||||
{
|
||||
label: 'Is favorited',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilters({
|
||||
ndParams: { ...filter.ndParams, starred: e.currentTarget.checked ? true : undefined },
|
||||
});
|
||||
},
|
||||
value: filter.ndParams?.starred,
|
||||
},
|
||||
{
|
||||
label: 'Is compilation',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilters({
|
||||
ndParams: { ...filter.ndParams, compilation: e.currentTarget.checked ? true : undefined },
|
||||
});
|
||||
},
|
||||
value: filter.ndParams?.compilation,
|
||||
},
|
||||
{
|
||||
label: 'Is recently played',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilters({
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
recently_played: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
value: filter.ndParams?.recently_played,
|
||||
},
|
||||
];
|
||||
|
||||
const handleYearFilter = debounce((e: number | undefined) => {
|
||||
setFilters({
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
year: e,
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<Stack p="0.8rem">
|
||||
{toggleFilters.map((filter) => (
|
||||
<Group
|
||||
key={`nd-filter-${filter.label}`}
|
||||
position="apart"
|
||||
>
|
||||
<Text>{filter.label}</Text>
|
||||
<Switch
|
||||
checked={filter?.value || false}
|
||||
size="xs"
|
||||
onChange={filter.onChange}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="0.5rem" />
|
||||
<Group position="apart">
|
||||
<Text>Year</Text>
|
||||
<NumberInput
|
||||
max={5000}
|
||||
min={0}
|
||||
size="xs"
|
||||
value={filter.ndParams?.year}
|
||||
width={50}
|
||||
onChange={handleYearFilter}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
Reference in a new issue