Subsonic 2, general rework (#758)

This commit is contained in:
Kendall Garner 2024-09-26 04:23:08 +00:00 committed by GitHub
parent 31492fa9ef
commit 8cddbef701
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 4625 additions and 3566 deletions

View file

@ -11,7 +11,13 @@ import { generatePath, useParams } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
QueueSong,
SortOrder,
} from '/@/renderer/api/types';
import { Button, Popover, Spoiler } from '/@/renderer/components';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import {
@ -164,13 +170,12 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
query: {
_custom: {
jellyfin: {
AlbumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
ExcludeItemIds: detailQuery?.data?.id,
},
navidrome: {
artist_id: detailQuery?.data?.albumArtists[0]?.id,
},
},
artistIds: detailQuery?.data?.albumArtists.length
? [detailQuery?.data?.albumArtists[0].id]
: undefined,
limit: 15,
sortBy: AlbumListSort.YEAR,
sortOrder: SortOrder.DESC,
@ -179,15 +184,8 @@ export const AlbumDetailContent = ({ tableRef, background }: AlbumDetailContentP
serverId: server?.id,
});
const relatedAlbumGenresRequest = {
_custom: {
jellyfin: {
GenreIds: detailQuery?.data?.genres?.[0]?.id,
},
navidrome: {
genre_id: detailQuery?.data?.genres?.[0]?.id,
},
},
const relatedAlbumGenresRequest: AlbumListQuery = {
genres: detailQuery.data?.genres.length ? [detailQuery.data.genres[0].id] : undefined,
limit: 15,
sortBy: AlbumListSort.RANDOM,
sortOrder: SortOrder.ASC,

View file

@ -29,7 +29,7 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const { pageKey, customFilters, id } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
const { grid, display, filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
const { setGrid } = useListStoreActions();
const [searchParams, setSearchParams] = useSearchParams();
@ -162,9 +162,9 @@ export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
const query: AlbumListQuery = {
limit: take,
startIndex: skip,
...filter,
...customFilters,
startIndex: skip,
};
const queryKey = queryKeys.albums.list(server?.id || '', query, id);

View file

@ -15,13 +15,20 @@ import {
RiSettings3Fill,
} from 'react-icons/ri';
import { queryKeys } from '/@/renderer/api/query-keys';
import { AlbumListSort, LibraryItem, ServerType, SortOrder } from '/@/renderer/api/types';
import {
AlbumListQuery,
AlbumListSort,
LibraryItem,
ServerType,
SortOrder,
} from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { useListContext } from '/@/renderer/context/list-context';
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
import { SubsonicAlbumFilters } from '/@/renderer/features/albums/components/subsonic-album-filters';
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { useContainerQuery } from '/@/renderer/hooks';
import { useListFilterRefresh } from '/@/renderer/hooks/use-list-filter-refresh';
@ -139,26 +146,74 @@ const FILTERS = {
value: AlbumListSort.YEAR,
},
],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.albumArtist', { postProcess: 'titleCase' }),
value: AlbumListSort.ALBUM_ARTIST,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }),
value: AlbumListSort.PLAY_COUNT,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: AlbumListSort.NAME,
},
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.random', { postProcess: 'titleCase' }),
value: AlbumListSort.RANDOM,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_ADDED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.recentlyPlayed', { postProcess: 'titleCase' }),
value: AlbumListSort.RECENTLY_PLAYED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.favorited', { postProcess: 'titleCase' }),
value: AlbumListSort.FAVORITED,
},
{
defaultOrder: SortOrder.DESC,
name: i18n.t('filter.releaseYear', { postProcess: 'titleCase' }),
value: AlbumListSort.YEAR,
},
],
};
interface AlbumListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount: number | undefined;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => {
export const AlbumListHeaderFilters = ({
gridRef,
itemCount,
tableRef,
}: AlbumListHeaderFiltersProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { pageKey, customFilters, handlePlay } = useListContext();
const server = useCurrentServer();
const { setFilter, setTable, setGrid, setDisplayType } = useListStoreActions();
const { display, filter, table, grid } = useListStoreByKey({
const { display, filter, table, grid } = useListStoreByKey<AlbumListQuery>({
filter: customFilters,
key: pageKey,
});
const cq = useContainerQuery();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.ALBUM,
server,
});
@ -191,27 +246,35 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
);
const handleOpenFiltersModal = () => {
let FilterComponent;
switch (server?.type) {
case ServerType.NAVIDROME:
FilterComponent = NavidromeAlbumFilters;
break;
case ServerType.JELLYFIN:
FilterComponent = JellyfinAlbumFilters;
break;
case ServerType.SUBSONIC:
FilterComponent = SubsonicAlbumFilters;
break;
default:
break;
}
if (!FilterComponent) {
return;
}
openModal({
children: (
<>
{server?.type === ServerType.NAVIDROME ? (
<NavidromeAlbumFilters
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
) : (
<JellyfinAlbumFilters
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
)}
</>
<FilterComponent
customFilters={customFilters}
disableArtistFilter={!!customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
),
title: 'Album Filters',
});
@ -347,8 +410,20 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil
filter?._custom?.jellyfin &&
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
return isNavidromeFilterApplied || isJellyfinFilterApplied;
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
const isSubsonicFilterApplied =
server?.type === ServerType.SUBSONIC &&
(filter.maxYear || filter.minYear || filter.genres?.length || filter.favorite);
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
}, [
filter?._custom?.jellyfin,
filter?._custom?.navidrome,
filter.favorite,
filter.genres?.length,
filter.maxYear,
filter.minYear,
server?.type,
]);
const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined;

View file

@ -3,7 +3,7 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { LibraryItem } from '/@/renderer/api/types';
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/album-list-header-filters';
@ -33,8 +33,9 @@ export const AlbumListHeader = ({
const cq = useContainerQuery();
const playButtonBehavior = usePlayButtonBehavior();
const genreRef = useRef<string>();
const { filter, handlePlay, refresh, search } = useDisplayRefresh({
const { filter, handlePlay, refresh, search } = useDisplayRefresh<AlbumListQuery>({
gridRef,
itemCount,
itemType: LibraryItem.ALBUM,
server,
tableRef,
@ -90,6 +91,7 @@ export const AlbumListHeader = ({
<FilterBar>
<AlbumListHeaderFilters
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar>

View file

@ -3,7 +3,13 @@ import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { useListFilterByKey } from '../../../store/list.store';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import {
AlbumArtistListSort,
AlbumListQuery,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/renderer/api/types';
import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer/components';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { useGenreList } from '/@/renderer/features/genres';
@ -25,7 +31,7 @@ export const JellyfinAlbumFilters = ({
serverId,
}: JellyfinAlbumFiltersProps) => {
const { t } = useTranslation();
const filter = useListFilterByKey({ key: pageKey });
const filter = useListFilterByKey<AlbumListQuery>({ key: pageKey });
const { setFilter } = useListStoreActions();
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
@ -47,10 +53,6 @@ export const JellyfinAlbumFilters = ({
}));
}, [genreListQuery.data]);
const selectedGenres = useMemo(() => {
return filter?._custom?.jellyfin?.GenreIds?.split(',');
}, [filter?._custom?.jellyfin?.GenreIds]);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
@ -58,20 +60,15 @@ export const JellyfinAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
IsFavorite: e.currentTarget.checked ? true : undefined,
},
},
_custom: filter?._custom,
favorite: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter?._custom?.jellyfin?.IsFavorite,
value: filter?.favorite,
},
];
@ -80,13 +77,8 @@ export const JellyfinAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
minYear: e === '' ? undefined : (e as number),
},
},
_custom: filter?._custom,
minYear: e === '' ? undefined : (e as number),
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@ -99,13 +91,8 @@ export const JellyfinAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
maxYear: e === '' ? undefined : (e as number),
},
},
_custom: filter?._custom,
maxYear: e === '' ? undefined : (e as number),
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@ -114,17 +101,11 @@ export const JellyfinAlbumFilters = ({
}, 500);
const handleGenresFilter = debounce((e: string[] | undefined) => {
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
GenreIds: genreFilterString,
},
},
_custom: filter?._custom,
genres: e,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@ -157,17 +138,11 @@ export const JellyfinAlbumFilters = ({
}, [albumArtistListQuery?.data?.items]);
const handleAlbumArtistFilter = (e: string[] | null) => {
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
AlbumArtistIds: albumArtistFilterString,
},
},
_custom: filter?._custom,
artistIds: e || undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@ -193,21 +168,21 @@ export const JellyfinAlbumFilters = ({
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter?._custom?.jellyfin?.minYear}
defaultValue={filter?.minYear}
hideControls={false}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
required={!!filter?._custom?.jellyfin?.maxYear}
required={!!filter?.maxYear}
onChange={(e) => handleMinYearFilter(e)}
/>
<NumberInput
defaultValue={filter?._custom?.jellyfin?.maxYear}
defaultValue={filter?.maxYear}
hideControls={false}
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
required={!!filter?._custom?.jellyfin?.minYear}
required={!!filter?.minYear}
onChange={(e) => handleMaxYearFilter(e)}
/>
</Group>
@ -216,7 +191,7 @@ export const JellyfinAlbumFilters = ({
clearable
searchable
data={genreList}
defaultValue={selectedGenres}
defaultValue={filter.genres}
label={t('entity.genre', { count: 2, postProcess: 'sentenceCase' })}
onChange={handleGenresFilter}
/>

View file

@ -5,7 +5,13 @@ import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/rend
import debounce from 'lodash/debounce';
import { useGenreList } from '/@/renderer/features/genres';
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
import { AlbumArtistListSort, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import {
AlbumArtistListSort,
AlbumListQuery,
GenreListSort,
LibraryItem,
SortOrder,
} from '/@/renderer/api/types';
import { useTranslation } from 'react-i18next';
interface NavidromeAlbumFiltersProps {
@ -24,7 +30,7 @@ export const NavidromeAlbumFilters = ({
serverId,
}: NavidromeAlbumFiltersProps) => {
const { t } = useTranslation();
const { filter } = useListStoreByKey({ key: pageKey });
const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList({
@ -48,13 +54,8 @@ export const NavidromeAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
genre_id: e || undefined,
},
},
_custom: filter._custom,
genres: e ? [e] : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
@ -90,20 +91,15 @@ export const NavidromeAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
starred: e.currentTarget.checked ? true : undefined,
},
},
_custom: filter._custom,
favorite: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.starred,
value: filter.favorite,
},
{
label: t('filter.isCompilation', { postProcess: 'sentenceCase' }),
@ -111,20 +107,15 @@ export const NavidromeAlbumFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
...filter._custom?.navidrome,
compilation: e.currentTarget.checked ? true : undefined,
},
},
_custom: filter._custom,
compilation: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.compilation,
value: filter.compilation,
},
{
label: t('filter.isRecentlyPlayed', { postProcess: 'sentenceCase' }),

View file

@ -0,0 +1,141 @@
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { ChangeEvent, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AlbumListQuery, GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { AlbumListFilter, useListStoreActions, useListStoreByKey } from '/@/renderer/store';
interface SubsonicAlbumFiltersProps {
onFilterChange: (filters: AlbumListFilter) => void;
pageKey: string;
serverId?: string;
}
export const SubsonicAlbumFilters = ({
onFilterChange,
pageKey,
serverId,
}: SubsonicAlbumFiltersProps) => {
const { t } = useTranslation();
const { filter } = useListStoreByKey<AlbumListQuery>({ key: pageKey });
const { setFilter } = useListStoreActions();
const genreListQuery = useGenreList({
query: {
sortBy: GenreListSort.NAME,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
serverId,
});
const genreList = useMemo(() => {
if (!genreListQuery?.data) return [];
return genreListQuery.data.items.map((genre) => ({
label: genre.name,
value: genre.id,
}));
}, [genreListQuery.data]);
const handleGenresFilter = debounce((e: string | null) => {
const updatedFilters = setFilter({
data: {
genres: e ? [e] : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
data: {
favorite: e.target.checked ? true : undefined,
},
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
},
value: filter.favorite,
},
];
const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => {
let data: Partial<AlbumListQuery> = {};
if (type === 'min') {
data = {
minYear: e ? Number(e) : undefined,
};
} else {
data = {
maxYear: e ? Number(e) : undefined,
};
}
const updatedFilters = setFilter({
data,
itemType: LibraryItem.ALBUM,
key: pageKey,
}) as AlbumListFilter;
onFilterChange(updatedFilters);
}, 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}
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
<NumberInput
defaultValue={filter.minYear}
disabled={filter.genres?.length !== undefined}
hideControls={false}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e, 'min')}
/>
<NumberInput
defaultValue={filter.maxYear}
disabled={filter.genres?.length !== undefined}
hideControls={false}
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={5000}
min={0}
onChange={(e) => handleYearFilter(e, 'max')}
/>
</Group>
<Group grow>
<Select
clearable
searchable
data={genreList}
defaultValue={filter.genres?.length ? filter.genres[0] : undefined}
disabled={Boolean(filter.minYear || filter.maxYear)}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
onChange={handleGenresFilter}
/>
</Group>
</Stack>
);
};