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

@ -1,7 +1,7 @@
import { ChangeEvent, useMemo } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
import { MultiSelect, NumberInput, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
@ -22,7 +22,7 @@ export const JellyfinSongFilters = ({
}: JellyfinSongFiltersProps) => {
const { t } = useTranslation();
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey({ key: pageKey });
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
const isGenrePage = customFilters?._custom?.jellyfin?.GenreIds !== undefined;
@ -61,16 +61,16 @@ export const JellyfinSongFilters = ({
jellyfin: {
...filter?._custom?.jellyfin,
IncludeItemTypes: 'Audio',
IsFavorite: e.currentTarget.checked ? true : undefined,
},
},
favorite: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
},
value: filter?._custom?.jellyfin?.IsFavorite,
value: filter.favorite,
},
];
@ -84,9 +84,9 @@ export const JellyfinSongFilters = ({
jellyfin: {
...filter?._custom?.jellyfin,
IncludeItemTypes: 'Audio',
minYear: e === '' ? undefined : (e as number),
},
},
minYear: e === '' ? undefined : (e as number),
},
itemType: LibraryItem.SONG,
key: pageKey,
@ -104,9 +104,9 @@ export const JellyfinSongFilters = ({
jellyfin: {
...filter?._custom?.jellyfin,
IncludeItemTypes: 'Audio',
maxYear: e === '' ? undefined : (e as number),
},
},
maxYear: e === '' ? undefined : (e as number),
},
itemType: LibraryItem.SONG,
key: pageKey,
@ -115,7 +115,6 @@ export const JellyfinSongFilters = ({
}, 500);
const handleGenresFilter = debounce((e: string[] | undefined) => {
const genreFilterString = e?.length ? e.join(',') : undefined;
const updatedFilters = setFilter({
customFilters,
data: {
@ -123,10 +122,10 @@ export const JellyfinSongFilters = ({
...filter?._custom,
jellyfin: {
...filter?._custom?.jellyfin,
GenreIds: genreFilterString,
IncludeItemTypes: 'Audio',
},
},
genreIds: e,
},
itemType: LibraryItem.SONG,
key: pageKey,
@ -151,18 +150,19 @@ export const JellyfinSongFilters = ({
<Divider my="0.5rem" />
<Group grow>
<NumberInput
required
defaultValue={filter?._custom?.jellyfin?.minYear}
defaultValue={filter?.minYear}
label={t('filter.fromYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
required={!!filter?.minYear}
onChange={handleMinYearFilter}
/>
<NumberInput
defaultValue={filter?._custom?.jellyfin?.maxYear}
defaultValue={filter?.maxYear}
label={t('filter.toYear', { postProcess: 'sentenceCase' })}
max={2300}
min={1700}
required={!!filter?.minYear}
onChange={handleMaxYearFilter}
/>
</Group>

View file

@ -1,7 +1,7 @@
import { ChangeEvent, useMemo } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
import { NumberInput, Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
@ -22,9 +22,9 @@ export const NavidromeSongFilters = ({
}: NavidromeSongFiltersProps) => {
const { t } = useTranslation();
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey({ key: pageKey });
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
const isGenrePage = customFilters?._custom?.navidrome?.genre_id !== undefined;
const isGenrePage = customFilters?.genreIds !== undefined;
const genreListQuery = useGenreList({
query: {
@ -47,12 +47,8 @@ export const NavidromeSongFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
genre_id: e || undefined,
},
},
_custom: filter._custom,
genreIds: e ? [e] : undefined,
},
itemType: LibraryItem.SONG,
key: pageKey,
@ -68,12 +64,8 @@ export const NavidromeSongFilters = ({
const updatedFilters = setFilter({
customFilters,
data: {
_custom: {
...filter._custom,
navidrome: {
starred: e.currentTarget.checked ? true : undefined,
},
},
_custom: filter._custom,
favorite: e.currentTarget.checked ? true : undefined,
},
itemType: LibraryItem.SONG,
key: pageKey,
@ -81,7 +73,7 @@ export const NavidromeSongFilters = ({
onFilterChange(updatedFilters);
},
value: filter._custom?.navidrome?.starred,
value: filter.favorite,
},
];
@ -133,7 +125,7 @@ export const NavidromeSongFilters = ({
clearable
searchable
data={genreList}
defaultValue={filter._custom?.navidrome?.genre_id}
defaultValue={filter.genreIds ? filter.genreIds[0] : undefined}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
width={150}
onChange={handleGenresFilter}

View file

@ -36,7 +36,7 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps)
const server = useCurrentServer();
const handlePlayQueueAdd = usePlayQueueAdd();
const { pageKey, customFilters, id } = useListContext();
const { grid, display, filter } = useListStoreByKey({ key: pageKey });
const { grid, display, filter } = useListStoreByKey<SongListQuery>({ key: pageKey });
const { setGrid } = useListStoreActions();
const [searchParams, setSearchParams] = useSearchParams();
@ -174,9 +174,9 @@ export const SongListGridView = ({ gridRef, itemCount }: SongListGridViewProps)
const query: SongListQuery = {
imageSize: 250,
limit: take,
startIndex: skip,
...filter,
...customFilters,
startIndex: skip,
};
const queryKey = queryKeys.songs.list(server?.id || '', query, id);

View file

@ -15,7 +15,13 @@ import {
} from 'react-icons/ri';
import { useListStoreByKey } from '../../../store/list.store';
import { queryKeys } from '/@/renderer/api/query-keys';
import { LibraryItem, ServerType, SongListSort, SortOrder } from '/@/renderer/api/types';
import {
LibraryItem,
ServerType,
SongListQuery,
SongListSort,
SortOrder,
} from '/@/renderer/api/types';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
@ -29,6 +35,7 @@ import { queryClient } from '/@/renderer/lib/react-query';
import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { ListDisplayType, Play, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n';
import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filter';
const FILTERS = {
jellyfin: [
@ -165,25 +172,39 @@ const FILTERS = {
value: SongListSort.YEAR,
},
],
subsonic: [
{
defaultOrder: SortOrder.ASC,
name: i18n.t('filter.name', { postProcess: 'titleCase' }),
value: SongListSort.NAME,
},
],
};
interface SongListHeaderFiltersProps {
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
itemCount?: number;
tableRef: MutableRefObject<AgGridReactType | null>;
}
export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => {
export const SongListHeaderFilters = ({
gridRef,
itemCount,
tableRef,
}: SongListHeaderFiltersProps) => {
const { t } = useTranslation();
const server = useCurrentServer();
const { pageKey, handlePlay, customFilters } = useListContext();
const { display, table, filter, grid } = useListStoreByKey({
const { display, table, filter, grid } = useListStoreByKey<SongListQuery>({
filter: customFilters,
key: pageKey,
});
const { setFilter, setGrid, setTable, setTablePagination, setDisplayType } =
useListStoreActions();
const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({
itemCount,
itemType: LibraryItem.SONG,
server,
});
@ -392,25 +413,32 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
};
const handleOpenFiltersModal = () => {
let FilterComponent;
switch (server?.type) {
case ServerType.NAVIDROME:
FilterComponent = NavidromeSongFilters;
break;
case ServerType.JELLYFIN:
FilterComponent = JellyfinSongFilters;
break;
case ServerType.SUBSONIC:
FilterComponent = SubsonicSongFilters;
break;
}
if (!FilterComponent) {
return;
}
openModal({
children: (
<>
{server?.type === ServerType.NAVIDROME ? (
<NavidromeSongFilters
customFilters={customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
) : (
<JellyfinSongFilters
customFilters={customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
)}
</>
<FilterComponent
customFilters={customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
),
title: 'Song Filters',
});
@ -429,8 +457,16 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
.filter((value) => value !== 'Audio') // Don't account for includeItemTypes: Audio
.some((value) => value !== undefined);
return isNavidromeFilterApplied || isJellyfinFilterApplied;
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
const isGenericFilterApplied = filter?.favorite || filter?.genreIds?.length;
return isNavidromeFilterApplied || isJellyfinFilterApplied || isGenericFilterApplied;
}, [
filter._custom?.jellyfin,
filter._custom?.navidrome,
filter?.favorite,
filter?.genreIds?.length,
server?.type,
]);
const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined;
@ -467,11 +503,15 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte
))}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Divider orientation="vertical" />
<OrderToggleButton
sortOrder={filter.sortOrder}
onToggle={handleToggleSortOrder}
/>
{server?.type !== ServerType.SUBSONIC && (
<>
<Divider orientation="vertical" />
<OrderToggleButton
sortOrder={filter.sortOrder}
onToggle={handleToggleSortOrder}
/>
</>
)}
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />

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 { LibraryItem, SongListQuery } from '/@/renderer/api/types';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { SongListHeaderFilters } from '/@/renderer/features/songs/components/song-list-header-filters';
@ -33,12 +33,15 @@ export const SongListHeader = ({
const cq = useContainerQuery();
const genreRef = useRef<string>();
const { customFilters, filter, handlePlay, refresh, search } = useDisplayRefresh({
gridRef,
itemType: LibraryItem.SONG,
server,
tableRef,
});
const { customFilters, filter, handlePlay, refresh, search } = useDisplayRefresh<SongListQuery>(
{
gridRef,
itemCount,
itemType: LibraryItem.SONG,
server,
tableRef,
},
);
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = search(e) as SongListFilter;
@ -96,6 +99,7 @@ export const SongListHeader = ({
<FilterBar>
<SongListHeaderFilters
gridRef={gridRef}
itemCount={itemCount}
tableRef={tableRef}
/>
</FilterBar>

View file

@ -0,0 +1,112 @@
import { ChangeEvent, useMemo } from 'react';
import { Divider, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { GenreListSort, LibraryItem, SongListQuery, SortOrder } from '/@/renderer/api/types';
import { Select, Switch, Text } from '/@/renderer/components';
import { useGenreList } from '/@/renderer/features/genres';
import { SongListFilter, useListFilterByKey, useListStoreActions } from '/@/renderer/store';
import { useTranslation } from 'react-i18next';
interface SubsonicSongFiltersProps {
customFilters?: Partial<SongListFilter>;
onFilterChange: (filters: SongListFilter) => void;
pageKey: string;
serverId?: string;
}
export const SubsonicSongFilters = ({
customFilters,
onFilterChange,
pageKey,
serverId,
}: SubsonicSongFiltersProps) => {
const { t } = useTranslation();
const { setFilter } = useListStoreActions();
const filter = useListFilterByKey<SongListQuery>({ key: pageKey });
const isGenrePage = customFilters?.genreIds !== undefined;
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({
customFilters,
data: {
genreIds: e ? [e] : undefined,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
disabled: filter.genreIds !== undefined || isGenrePage || !!filter.searchTerm,
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
favorite: e.target.checked,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
},
value: filter.favorite,
},
];
return (
<Stack p="0.8rem">
{toggleFilters.map((filter) => (
<Group
key={`ss-filter-${filter.label}`}
position="apart"
>
<Text>{filter.label}</Text>
<Switch
checked={filter?.value || false}
disabled={filter.disabled}
size="xs"
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
{!isGenrePage && (
<Select
clearable
searchable
data={genreList}
defaultValue={filter.genreIds ? filter.genreIds[0] : undefined}
disabled={!!filter.searchTerm}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
width={150}
onChange={handleGenresFilter}
/>
)}
</Group>
</Stack>
);
};

View file

@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import type { SongListQuery } from '/@/renderer/api/types';
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
export const useSongListCount = (args: QueryHookArgs<SongListQuery>) => {
const { options, query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
enabled: !!serverId,
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
return api.controller.getSongListCount({
apiClientProps: {
server,
signal,
},
query,
});
},
queryKey: queryKeys.songs.count(
serverId || '',
Object.keys(query).length === 0 ? undefined : query,
),
...options,
});
};

View file

@ -10,11 +10,11 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
import { AnimatedPage } from '/@/renderer/features/shared';
import { SongListContent } from '/@/renderer/features/songs/components/song-list-content';
import { SongListHeader } from '/@/renderer/features/songs/components/song-list-header';
import { useSongList } from '/@/renderer/features/songs/queries/song-list-query';
import { useCurrentServer, useListFilterByKey } from '/@/renderer/store';
import { Play } from '/@/renderer/types';
import { titleCase } from '/@/renderer/utils';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useSongListCount } from '/@/renderer/features/songs/queries/song-list-count-query';
const TrackListRoute = () => {
const { t } = useTranslation();
@ -30,14 +30,7 @@ const TrackListRoute = () => {
const value = {
...(albumArtistId && { artistIds: [albumArtistId] }),
...(genreId && {
_custom: {
jellyfin: {
GenreIds: genreId,
},
navidrome: {
genre_id: genreId,
},
},
genreIds: [genreId],
}),
};
@ -76,29 +69,22 @@ const TrackListRoute = () => {
return genre?.name;
}, [genreId, genreList.data]);
const itemCountCheck = useSongList({
const itemCountCheck = useSongListCount({
options: {
cacheTime: 1000 * 60,
staleTime: 1000 * 60,
},
query: {
limit: 1,
startIndex: 0,
...songListFilter,
},
query: songListFilter,
serverId: server?.id,
});
const itemCount =
itemCountCheck.data?.totalRecordCount === null
? undefined
: itemCountCheck.data?.totalRecordCount;
const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data;
const handlePlay = useCallback(
async (args: { initialSongId?: string; playType: Play }) => {
if (!itemCount || itemCount === 0) return;
const { initialSongId, playType } = args;
const query: SongListQuery = { startIndex: 0, ...songListFilter };
const query: SongListQuery = { ...songListFilter, limit: itemCount, startIndex: 0 };
if (albumArtistId) {
handlePlayQueueAdd?.({