Add server-specific album filters

This commit is contained in:
jeffvli 2022-12-22 01:58:11 -08:00
parent 223cf469f4
commit 57c34637cf
7 changed files with 323 additions and 39 deletions

View file

@ -247,7 +247,16 @@ const getAlbumDetail = async (args: AlbumDetailArgs): Promise<JFAlbumDetail> =>
const getAlbumList = async (args: AlbumListArgs): Promise<JFAlbumList> => { const getAlbumList = async (args: AlbumListArgs): Promise<JFAlbumList> => {
const { query, server, signal } = args; 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', includeItemTypes: 'MusicAlbum',
limit: query.limit, limit: query.limit,
parentId: query.musicFolderId, parentId: query.musicFolderId,
@ -257,6 +266,9 @@ const getAlbumList = async (args: AlbumListArgs): Promise<JFAlbumList> => {
sortOrder: sortOrderMap.jellyfin[query.sortOrder], sortOrder: sortOrderMap.jellyfin[query.sortOrder],
startIndex: query.startIndex, startIndex: query.startIndex,
...query.jfParams, ...query.jfParams,
maxYear: undefined,
minYear: undefined,
years: yearsFilter,
}; };
const data = await api const data = await api

View file

@ -499,11 +499,16 @@ export enum JFAlbumListSort {
} }
export type JFAlbumListParams = { export type JFAlbumListParams = {
albumArtistIds?: string;
artistIds?: string;
filters?: string; filters?: string;
genreIds?: string;
genres?: string; genres?: string;
includeItemTypes: 'MusicAlbum'; includeItemTypes: 'MusicAlbum';
isFavorite?: boolean;
searchTerm?: string; searchTerm?: string;
sortBy?: JFAlbumListSort; sortBy?: JFAlbumListSort;
tags?: string;
years?: string; years?: string;
} & JFBaseParams & } & JFBaseParams &
JFPaginationParams; JFPaginationParams;

View file

@ -215,7 +215,7 @@ const getAlbumList = async (args: AlbumListArgs): Promise<NDAlbumList> => {
const res = await api.get('api/album', { const res = await api.get('api/album', {
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` }, headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
prefixUrl: server?.url, prefixUrl: server?.url,
searchParams, searchParams: parseSearchParams(searchParams),
signal, signal,
}); });

View file

@ -287,9 +287,15 @@ export enum AlbumListSort {
export type AlbumListQuery = { export type AlbumListQuery = {
jfParams?: { jfParams?: {
albumArtistIds?: string;
artistIds?: string;
filters?: string; filters?: string;
genreIds?: string;
genres?: string; genres?: string;
years?: string; isFavorite?: boolean;
maxYear?: number; // Parses to years
minYear?: number; // Parses to years
tags?: string;
}; };
limit?: number; limit?: number;
musicFolderId?: string; musicFolderId?: string;
@ -299,6 +305,7 @@ export type AlbumListQuery = {
genre_id?: string; genre_id?: string;
has_rating?: boolean; has_rating?: boolean;
name?: string; name?: string;
recently_played?: boolean;
starred?: boolean; starred?: boolean;
year?: number; year?: number;
}; };

View file

@ -3,9 +3,22 @@ import { useCallback } from 'react';
import { Flex, Slider } from '@mantine/core'; import { Flex, Slider } from '@mantine/core';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import { RiArrowDownSLine } from 'react-icons/ri'; import {
import { AlbumListSort, SortOrder } from '/@/renderer/api/types'; RiArrowDownSLine,
import { Button, DropdownMenu, PageHeader, SearchInput } from '/@/renderer/components'; 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 { import {
useCurrentServer, useCurrentServer,
useAlbumListStore, useAlbumListStore,
@ -15,6 +28,8 @@ import {
import { CardDisplayType } from '/@/renderer/types'; import { CardDisplayType } from '/@/renderer/types';
import { useMusicFolders } from '/@/renderer/features/shared'; import { useMusicFolders } from '/@/renderer/features/shared';
import styled from 'styled-components'; 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 = { const FILTERS = {
jellyfin: [ jellyfin: [
@ -35,6 +50,7 @@ const FILTERS = {
{ name: 'Random', value: AlbumListSort.RANDOM }, { name: 'Random', value: AlbumListSort.RANDOM },
{ name: 'Rating', value: AlbumListSort.RATING }, { name: 'Rating', value: AlbumListSort.RATING },
{ name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED }, { name: 'Recently Added', value: AlbumListSort.RECENTLY_ADDED },
{ name: 'Recently Played', value: AlbumListSort.RECENTLY_PLAYED },
{ name: 'Song Count', value: AlbumListSort.SONG_COUNT }, { name: 'Song Count', value: AlbumListSort.SONG_COUNT },
{ name: 'Favorited', value: AlbumListSort.FAVORITED }, { name: 'Favorited', value: AlbumListSort.FAVORITED },
{ name: 'Year', value: AlbumListSort.YEAR }, { name: 'Year', value: AlbumListSort.YEAR },
@ -68,8 +84,6 @@ export const AlbumListHeader = () => {
)?.name) || )?.name) ||
'Unknown'; 'Unknown';
const sortOrderLabel = ORDER.find((s) => s.value === filters.sortOrder)?.name;
const setSize = throttle( const setSize = throttle(
(e: number) => (e: number) =>
setPage({ setPage({
@ -101,13 +115,9 @@ export const AlbumListHeader = () => {
const handleSetOrder = useCallback( const handleSetOrder = useCallback(
(e: MouseEvent<HTMLButtonElement>) => { (e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return; if (!e.currentTarget?.value) return;
debounce( setFilter({
() => sortOrder: e.currentTarget.value as SortOrder,
setFilter({ });
sortOrder: e.currentTarget.value as SortOrder,
}),
1000,
);
}, },
[setFilter], [setFilter],
); );
@ -153,19 +163,25 @@ export const AlbumListHeader = () => {
<HeaderItems> <HeaderItems>
<Flex <Flex
align="center" align="center"
gap="sm" gap="md"
justify="center" justify="center"
> >
<DropdownMenu position="bottom-end"> <DropdownMenu position="bottom">
<DropdownMenu.Target> <DropdownMenu.Target>
<Button <Button
compact compact
pl={0}
pr="0.5rem"
rightIcon={<RiArrowDownSLine size={15} />} rightIcon={<RiArrowDownSLine size={15} />}
size="xl" size="xl"
sx={{ paddingLeft: 0, paddingRight: 0 }}
variant="subtle" variant="subtle"
> >
Albums <TextTitle
fw="bold"
order={2}
>
Albums
</TextTitle>
</Button> </Button>
</DropdownMenu.Target> </DropdownMenu.Target>
<DropdownMenu.Dropdown> <DropdownMenu.Dropdown>
@ -201,7 +217,7 @@ export const AlbumListHeader = () => {
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Dropdown> </DropdownMenu.Dropdown>
</DropdownMenu> </DropdownMenu>
<DropdownMenu position="bottom-start"> <DropdownMenu position="bottom">
<DropdownMenu.Target> <DropdownMenu.Target>
<Button <Button
compact compact
@ -224,14 +240,18 @@ export const AlbumListHeader = () => {
))} ))}
</DropdownMenu.Dropdown> </DropdownMenu.Dropdown>
</DropdownMenu> </DropdownMenu>
<DropdownMenu position="bottom-start"> <DropdownMenu position="bottom">
<DropdownMenu.Target> <DropdownMenu.Target>
<Button <Button
compact compact
fw="normal" fw="normal"
variant="subtle" variant="subtle"
> >
{sortOrderLabel} {filters.sortOrder === SortOrder.ASC ? (
<RiSortAsc size={15} />
) : (
<RiSortDesc size={15} />
)}
</Button> </Button>
</DropdownMenu.Target> </DropdownMenu.Target>
<DropdownMenu.Dropdown> <DropdownMenu.Dropdown>
@ -247,29 +267,49 @@ export const AlbumListHeader = () => {
))} ))}
</DropdownMenu.Dropdown> </DropdownMenu.Dropdown>
</DropdownMenu> </DropdownMenu>
<DropdownMenu position="bottom-start"> {server?.type === ServerType.JELLYFIN && (
<DropdownMenu.Target> <DropdownMenu position="bottom">
<DropdownMenu.Target>
<Button
compact
fw="normal"
variant="subtle"
>
<RiFolder2Line size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.map((folder) => (
<DropdownMenu.Item
key={`musicFolder-${folder.id}`}
$isActive={filters.musicFolderId === folder.id}
value={folder.id}
onClick={handleSetMusicFolder}
>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
)}
<Popover>
<Popover.Target>
<Button <Button
compact compact
fw="normal" fw="normal"
variant="subtle" variant="subtle"
> >
Folder <RiFilter3Line size={15} />
</Button> </Button>
</DropdownMenu.Target> </Popover.Target>
<DropdownMenu.Dropdown> <Popover.Dropdown>
{musicFoldersQuery.data?.map((folder) => ( {server?.type === ServerType.NAVIDROME ? (
<DropdownMenu.Item <NavidromeAlbumFilters />
key={`musicFolder-${folder.id}`} ) : (
$isActive={filters.musicFolderId === folder.id} <JellyfinAlbumFilters />
value={folder.id} )}
onClick={handleSetMusicFolder} </Popover.Dropdown>
> </Popover>
{folder.name}
</DropdownMenu.Item>
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
</Flex> </Flex>
<Flex> <Flex>
<SearchInput <SearchInput

View file

@ -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>
);
};

View file

@ -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>
);
};