Improve Jellyfin/Navidrome Album/Song filter, Navidrome artist recent release

- Use `compilation=false` for Navidrome recent releases with artist credit
- Add `YesNoSelect` (yes, no, undefined) for `favorite` for Navidrome/Jellyfin `album`/`track`, and Navidrome `compilation`
- Fix folderButton translation
This commit is contained in:
Kendall Garner 2025-06-29 22:14:06 -07:00
parent b5bdea1845
commit 5456c2c2b8
No known key found for this signature in database
GPG key ID: 9355F387FE765C94
10 changed files with 119 additions and 62 deletions

View file

@ -414,18 +414,23 @@ export const AlbumListHeaderFilters = ({
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined); Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
const isSubsonicFilterApplied = const isSubsonicFilterApplied =
server?.type === ServerType.SUBSONIC && server?.type === ServerType.SUBSONIC && (filter.maxYear || filter.minYear);
(filter.maxYear || filter.minYear || filter.favorite);
const isCompilationFilterApplied =
server?.type === ServerType.NAVIDROME && filter.compilation !== undefined;
return ( return (
isNavidromeFilterApplied || isNavidromeFilterApplied ||
isJellyfinFilterApplied || isJellyfinFilterApplied ||
isSubsonicFilterApplied || isSubsonicFilterApplied ||
filter.genres?.length filter.genres?.length ||
filter.favorite !== undefined ||
isCompilationFilterApplied
); );
}, [ }, [
filter?._custom?.jellyfin, filter?._custom?.jellyfin,
filter?._custom?.navidrome, filter?._custom?.navidrome,
filter.compilation,
filter.favorite, filter.favorite,
filter.genres?.length, filter.genres?.length,
filter.maxYear, filter.maxYear,

View file

@ -1,5 +1,5 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@ -12,8 +12,8 @@ import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner'; import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { import {
AlbumArtistListSort, AlbumArtistListSort,
AlbumListQuery, AlbumListQuery,
@ -72,15 +72,15 @@ export const JellyfinAlbumFilters = ({
return filter?._custom?.jellyfin?.Tags?.split('|'); return filter?._custom?.jellyfin?.Tags?.split('|');
}, [filter?._custom?.jellyfin?.Tags]); }, [filter?._custom?.jellyfin?.Tags]);
const toggleFilters = [ const yesNoFilter = [
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (favorite?: boolean) => {
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: filter?._custom, _custom: filter?._custom,
favorite: e.currentTarget.checked ? true : undefined, favorite,
}, },
itemType: LibraryItem.ALBUM, itemType: LibraryItem.ALBUM,
key: pageKey, key: pageKey,
@ -189,16 +189,16 @@ export const JellyfinAlbumFilters = ({
return ( return (
<Stack p="0.8rem"> <Stack p="0.8rem">
{toggleFilters.map((filter) => ( {yesNoFilter.map((filter) => (
<Group <Group
justify="space-between" justify="space-between"
key={`nd-filter-${filter.label}`} key={`nd-filter-${filter.label}`}
> >
<Text>{filter.label}</Text> <Text>{filter.label}</Text>
<Switch <YesNoSelect
checked={filter?.value || false}
onChange={filter.onChange} onChange={filter.onChange}
size="xs" size="xs"
value={filter.value}
/> />
</Group> </Group>
))} ))}
@ -250,7 +250,7 @@ export const JellyfinAlbumFilters = ({
searchValue={albumArtistSearchTerm} searchValue={albumArtistSearchTerm}
/> />
</Group> </Group>
{tagsQuery.data?.boolTags?.length && ( {tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
<Group grow> <Group grow>
<MultiSelectWithInvalidData <MultiSelectWithInvalidData
clearable clearable

View file

@ -14,6 +14,7 @@ import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch'; import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { import {
AlbumArtistListSort, AlbumArtistListSort,
AlbumListQuery, AlbumListQuery,
@ -78,6 +79,41 @@ export const NavidromeAlbumFilters = ({
serverId, serverId,
}); });
const yesNoUndefinedFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (favorite?: boolean) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
favorite,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.favorite,
},
{
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
onChange: (compilation?: boolean) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
compilation,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.compilation,
},
];
const toggleFilters = [ const toggleFilters = [
{ {
label: t('filter.isRated', { postProcess: 'sentenceCase' }), label: t('filter.isRated', { postProcess: 'sentenceCase' }),
@ -100,38 +136,6 @@ export const NavidromeAlbumFilters = ({
}, },
value: filter._custom?.navidrome?.has_rating, value: filter._custom?.navidrome?.has_rating,
}, },
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
favorite: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.favorite,
},
{
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
_custom: filter._custom,
compilation: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.compilation,
},
{ {
label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }), label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (e: ChangeEvent<HTMLInputElement>) => {
@ -236,6 +240,19 @@ export const NavidromeAlbumFilters = ({
return ( return (
<Stack p="0.8rem"> <Stack p="0.8rem">
{yesNoUndefinedFilters.map((filter) => (
<Group
justify="space-between"
key={`nd-filter-${filter.label}`}
>
<Text>{filter.label}</Text>
<YesNoSelect
onChange={filter.onChange}
size="xs"
value={filter.value}
/>
</Group>
))}
{toggleFilters.map((filter) => ( {toggleFilters.map((filter) => (
<Group <Group
justify="space-between" justify="space-between"

View file

@ -103,6 +103,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
}, },
query: { query: {
artistIds: [routeId], artistIds: [routeId],
compilation: false,
limit: 15, limit: 15,
sortBy: AlbumListSort.RELEASE_DATE, sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC, sortOrder: SortOrder.DESC,

View file

@ -18,7 +18,7 @@ export const FolderButton = ({ isActive, ...props }: FolderButtonProps) => {
...props.iconProps, ...props.iconProps,
}} }}
tooltip={{ tooltip={{
label: t('entity.folder', { postProcess: 'sentenceCase' }), label: t('entity.folder', { count: 1, postProcess: 'sentenceCase' }),
...props.tooltip, ...props.tooltip,
}} }}
variant="subtle" variant="subtle"

View file

@ -1,5 +1,5 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { MultiSelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@ -10,8 +10,8 @@ import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types'; import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types';
interface JellyfinSongFiltersProps { interface JellyfinSongFiltersProps {
@ -69,10 +69,10 @@ export const JellyfinSongFilters = ({
return filter?._custom?.jellyfin?.Tags?.split('|'); return filter?._custom?.jellyfin?.Tags?.split('|');
}, [filter?._custom?.jellyfin?.Tags]); }, [filter?._custom?.jellyfin?.Tags]);
const toggleFilters = [ const yesNoFilters = [
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (favorite?: boolean) => {
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
@ -83,7 +83,7 @@ export const JellyfinSongFilters = ({
IncludeItemTypes: 'Audio', IncludeItemTypes: 'Audio',
}, },
}, },
favorite: e.currentTarget.checked ? true : undefined, favorite,
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
@ -174,15 +174,16 @@ export const JellyfinSongFilters = ({
return ( return (
<Stack p="0.8rem"> <Stack p="0.8rem">
{toggleFilters.map((filter) => ( {yesNoFilters.map((filter) => (
<Group <Group
justify="space-between" justify="space-between"
key={`nd-filter-${filter.label}`} key={`nd-filter-${filter.label}`}
> >
<Text>{filter.label}</Text> <Text>{filter.label}</Text>
<Switch <YesNoSelect
checked={filter?.value || false}
onChange={filter.onChange} onChange={filter.onChange}
size="xs"
value={filter.value}
/> />
</Group> </Group>
))} ))}
@ -218,7 +219,7 @@ export const JellyfinSongFilters = ({
/> />
</Group> </Group>
)} )}
{tagsQuery.data?.boolTags?.length && ( {tagsQuery.data?.boolTags && tagsQuery.data.boolTags.length > 0 && (
<Group grow> <Group grow>
<MultiSelectWithInvalidData <MultiSelectWithInvalidData
clearable clearable

View file

@ -1,5 +1,5 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data'; import { SelectWithInvalidData } from '/@/renderer/components/select-with-invalid-data';
@ -10,8 +10,8 @@ import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input'; import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { YesNoSelect } from '/@/shared/components/yes-no-select/yes-no-select';
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types'; import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/shared/types/domain-types';
interface NavidromeSongFiltersProps { interface NavidromeSongFiltersProps {
@ -93,12 +93,12 @@ export const NavidromeSongFilters = ({
const toggleFilters = [ const toggleFilters = [
{ {
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => { onChange: (favorite: boolean | undefined) => {
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
_custom: filter._custom, _custom: filter._custom,
favorite: e.currentTarget.checked ? true : undefined, favorite,
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,
@ -137,10 +137,10 @@ export const NavidromeSongFilters = ({
key={`nd-filter-${filter.label}`} key={`nd-filter-${filter.label}`}
> >
<Text>{filter.label}</Text> <Text>{filter.label}</Text>
<Switch <YesNoSelect
checked={filter?.value || false}
onChange={filter.onChange} onChange={filter.onChange}
size="xs" size="xs"
value={filter.value}
/> />
</Group> </Group>
))} ))}

View file

@ -467,7 +467,7 @@ export const SongListHeaderFilters = ({
.filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio .filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio
.some((value) => value !== undefined); .some((value) => value !== undefined);
const isGenericFilterApplied = filter?.favorite || filter?.genreIds?.length; const isGenericFilterApplied = filter?.favorite !== undefined || filter?.genreIds?.length;
return isNavidromeFilterApplied || isJellyfinFilterApplied || isGenericFilterApplied; return isNavidromeFilterApplied || isJellyfinFilterApplied || isGenericFilterApplied;
}, [ }, [

View file

@ -69,7 +69,7 @@ export const SubsonicSongFilters = ({
const updatedFilters = setFilter({ const updatedFilters = setFilter({
customFilters, customFilters,
data: { data: {
favorite: e.target.checked, favorite: e.target.checked ? true : undefined,
}, },
itemType: LibraryItem.SONG, itemType: LibraryItem.SONG,
key: pageKey, key: pageKey,

View file

@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import { Select, SelectProps } from '/@/shared/components/select/select';
export interface YesNoSelectProps extends Omit<SelectProps, 'data' | 'onChange' | 'value'> {
onChange: (e?: boolean) => void;
value?: boolean;
}
export const YesNoSelect = ({ onChange, value, ...props }: YesNoSelectProps) => {
const { t } = useTranslation();
return (
<Select
clearable
data={[
{
label: t('common.no', { postProcess: 'sentenceCase' }),
value: 'false',
},
{
label: t('common.yes', { postProcess: 'sentenceCase' }),
value: 'true',
},
]}
onChange={(e) => {
onChange(e ? e === 'true' : undefined);
}}
value={value !== undefined ? value.toString() : undefined}
{...props}
/>
);
};