Support subsonic song filters

This commit is contained in:
jeffvli
2023-12-19 14:58:52 -08:00
parent f7fcf6c079
commit d6cc6a4745
9 changed files with 286 additions and 37 deletions
@@ -219,7 +219,7 @@ export const SubsonicController: ControllerEndpoint = {
); );
let results = artists.map((artist) => let results = artists.map((artist) =>
subsonicNormalize.albumArtist(artist, apiClientProps.server), subsonicNormalize.albumArtist(artist, apiClientProps.server, 300),
); );
if (query.searchTerm) { if (query.searchTerm) {
@@ -880,11 +880,39 @@ export const SubsonicController: ControllerEndpoint = {
const artistDetailPromises = []; const artistDetailPromises = [];
let results: any[] = []; let results: any[] = [];
if (query.genreId) { if (query.searchTerm) {
const res = await subsonicApiClient(apiClientProps).search3({
query: {
albumCount: 0,
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
songCount: query.limit,
songOffset: query.startIndex,
},
});
if (res.status !== 200) {
fsLog.error('Failed to get song list');
throw new Error('Failed to get song list');
}
return {
items:
res.body['subsonic-response'].searchResult3?.song?.map((song) =>
subsonicNormalize.song(song, apiClientProps.server, ''),
) || [],
startIndex: query.startIndex,
totalRecordCount: null,
};
}
if (query.genre) {
const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ const res = await subsonicApiClient(apiClientProps).getSongsByGenre({
query: { query: {
count: query.limit, count: query.limit,
genre: query.genreId, genre: query.genre,
musicFolderId: query.musicFolderId, musicFolderId: query.musicFolderId,
offset: query.startIndex, offset: query.startIndex,
}, },
@@ -896,14 +924,39 @@ export const SubsonicController: ControllerEndpoint = {
} }
return { return {
items: res.body['subsonic-response'].songsByGenre.song.map((song) => items:
subsonicNormalize.song(song, apiClientProps.server, ''), res.body['subsonic-response'].songsByGenre.song?.map((song) =>
), subsonicNormalize.song(song, apiClientProps.server, ''),
) || [],
startIndex: 0, startIndex: 0,
totalRecordCount: null, totalRecordCount: null,
}; };
} }
if (query.isFavorite) {
const res = await subsonicApiClient(apiClientProps).getStarred({
query: {
musicFolderId: query.musicFolderId,
},
});
if (res.status !== 200) {
fsLog.error('Failed to get song list');
throw new Error('Failed to get song list');
}
const results =
res.body['subsonic-response'].starred.song?.map((song) =>
subsonicNormalize.song(song, apiClientProps.server, ''),
) || [];
return {
items: sortSongList(results, query.sortBy, query.sortOrder),
startIndex: 0,
totalRecordCount: res.body['subsonic-response'].starred.song?.length || 0,
};
}
if (query.albumIds || query.artistIds) { if (query.albumIds || query.artistIds) {
if (query.albumIds) { if (query.albumIds) {
for (const albumId of query.albumIds) { for (const albumId of query.albumIds) {
@@ -1009,13 +1062,48 @@ export const SubsonicController: ControllerEndpoint = {
let fetchNextSection = true; let fetchNextSection = true;
let sectionIndex = 0; let sectionIndex = 0;
if (query.genreId) { if (query.searchTerm) {
let fetchNextPage = true;
let startIndex = 0;
let totalRecordCount = 0;
while (fetchNextPage) {
const res = await subsonicApiClient(apiClientProps).search3({
query: {
albumCount: 0,
albumOffset: 0,
artistCount: 0,
artistOffset: 0,
query: query.searchTerm || '""',
songCount: 500,
songOffset: startIndex,
},
});
if (res.status !== 200) {
fsLog.error('Failed to get song list count');
throw new Error('Failed to get song list count');
}
const songCount = res.body['subsonic-response'].searchResult3.song?.length;
totalRecordCount += songCount;
startIndex += songCount;
// The max limit size for Subsonic is 500
fetchNextPage = songCount === 500;
}
return totalRecordCount;
}
if (query.genre) {
let totalRecordCount = 0; let totalRecordCount = 0;
while (fetchNextSection) { while (fetchNextSection) {
const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ const res = await subsonicApiClient(apiClientProps).getSongsByGenre({
query: { query: {
count: 1, count: 1,
genre: query.genreId, genre: query.genre,
musicFolderId: query.musicFolderId, musicFolderId: query.musicFolderId,
offset: sectionIndex, offset: sectionIndex,
}, },
@@ -1042,7 +1130,7 @@ export const SubsonicController: ControllerEndpoint = {
const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ const res = await subsonicApiClient(apiClientProps).getSongsByGenre({
query: { query: {
count: 500, count: 500,
genre: query.genreId, genre: query.genre,
musicFolderId: query.musicFolderId, musicFolderId: query.musicFolderId,
offset: startIndex, offset: startIndex,
}, },
@@ -1065,6 +1153,21 @@ export const SubsonicController: ControllerEndpoint = {
return totalRecordCount; return totalRecordCount;
} }
if (query.isFavorite) {
const res = await subsonicApiClient(apiClientProps).getStarred({
query: {
musicFolderId: query.musicFolderId,
},
});
if (res.status !== 200) {
fsLog.error('Failed to get song list');
throw new Error('Failed to get song list');
}
return res.body['subsonic-response'].starred.song?.length || 0;
}
let totalRecordCount = 0; let totalRecordCount = 0;
while (fetchNextSection) { while (fetchNextSection) {
@@ -38,13 +38,14 @@ const normalizeSong = (
item: z.infer<typeof SubsonicApi._baseTypes.song>, item: z.infer<typeof SubsonicApi._baseTypes.song>,
server: ServerListItem | null, server: ServerListItem | null,
deviceId: string, deviceId: string,
size?: number,
): QueueSong => { ): QueueSong => {
const imageUrl = const imageUrl =
getCoverArtUrl({ getCoverArtUrl({
baseUrl: server?.url, baseUrl: server?.url,
coverArtId: item.coverArt, coverArtId: item.coverArt,
credential: server?.credential, credential: server?.credential,
size: 100, size: size || 300,
}) || null; }) || null;
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`; const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
+2
View File
@@ -492,8 +492,10 @@ export type SongListQuery = {
}; };
albumIds?: string[]; albumIds?: string[];
artistIds?: string[]; artistIds?: string[];
genre?: string;
genreId?: string; genreId?: string;
imageSize?: number; imageSize?: number;
isFavorite?: boolean;
limit?: number; limit?: number;
maxYear?: number; maxYear?: number;
minYear?: number; minYear?: number;
@@ -34,6 +34,7 @@ interface UseAgGridProps<TFilter> {
columnType?: 'albumDetail' | 'generic'; columnType?: 'albumDetail' | 'generic';
contextMenu: SetContextMenuItems; contextMenu: SetContextMenuItems;
customFilters?: Partial<TFilter>; customFilters?: Partial<TFilter>;
isClientSide?: boolean;
isClientSideSort?: boolean; isClientSideSort?: boolean;
isSearchParams?: boolean; isSearchParams?: boolean;
itemCount?: number; itemCount?: number;
@@ -43,6 +44,8 @@ interface UseAgGridProps<TFilter> {
tableRef: MutableRefObject<AgGridReactType | null>; tableRef: MutableRefObject<AgGridReactType | null>;
} }
const BLOCK_SIZE = 500;
export const useVirtualTable = <TFilter>({ export const useVirtualTable = <TFilter>({
server, server,
tableRef, tableRef,
@@ -52,6 +55,7 @@ export const useVirtualTable = <TFilter>({
itemCount, itemCount,
customFilters, customFilters,
isSearchParams, isSearchParams,
isClientSide,
isClientSideSort, isClientSideSort,
columnType, columnType,
}: UseAgGridProps<TFilter>) => { }: UseAgGridProps<TFilter>) => {
@@ -183,7 +187,7 @@ export const useVirtualTable = <TFilter>({
} }
if (results.totalRecordCount === null) { if (results.totalRecordCount === null) {
const hasMoreRows = results?.items?.length === properties.filter.limit; const hasMoreRows = results?.items?.length === BLOCK_SIZE;
const lastRowIndex = hasMoreRows const lastRowIndex = hasMoreRows
? undefined ? undefined
: (properties.filter.offset || 0) + results.items.length; : (properties.filter.offset || 0) + results.items.length;
@@ -334,6 +338,7 @@ export const useVirtualTable = <TFilter>({
alwaysShowHorizontalScroll: true, alwaysShowHorizontalScroll: true,
autoFitColumns: properties.table.autoFit, autoFitColumns: properties.table.autoFit,
blockLoadDebounceMillis: 200, blockLoadDebounceMillis: 200,
cacheBlockSize: 500,
getRowId: (data: GetRowIdParams<any>) => data.data.id, getRowId: (data: GetRowIdParams<any>) => data.data.id,
infiniteInitialRowCount: itemCount || 100, infiniteInitialRowCount: itemCount || 100,
pagination: isPaginationEnabled, pagination: isPaginationEnabled,
@@ -348,10 +353,11 @@ export const useVirtualTable = <TFilter>({
: undefined, : undefined,
rowBuffer: 20, rowBuffer: 20,
rowHeight: properties.table.rowHeight || 40, rowHeight: properties.table.rowHeight || 40,
rowModelType: 'infinite' as RowModelType, rowModelType: isClientSide ? 'clientSide' : ('infinite' as RowModelType),
suppressRowDrag: true, suppressRowDrag: true,
}; };
}, [ }, [
isClientSide,
isPaginationEnabled, isPaginationEnabled,
isSearchParams, isSearchParams,
itemCount, itemCount,
@@ -29,6 +29,7 @@ import { queryClient } from '/@/renderer/lib/react-query';
import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store'; import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types'; import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filters';
const FILTERS = { const FILTERS = {
jellyfin: [ jellyfin: [
@@ -400,25 +401,34 @@ export const SongListHeaderFilters = ({
}; };
const handleOpenFiltersModal = () => { 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;
default:
break;
}
if (!FilterComponent) {
return;
}
openModal({ openModal({
children: ( children: (
<> <FilterComponent
{server?.type === ServerType.NAVIDROME ? ( customFilters={customFilters}
<NavidromeSongFilters pageKey={pageKey}
customFilters={customFilters} serverId={server?.id}
pageKey={pageKey} onFilterChange={onFilterChange}
serverId={server?.id} />
onFilterChange={onFilterChange}
/>
) : (
<JellyfinSongFilters
customFilters={customFilters}
pageKey={pageKey}
serverId={server?.id}
onFilterChange={onFilterChange}
/>
)}
</>
), ),
title: 'Song Filters', title: 'Song Filters',
}); });
@@ -437,8 +447,17 @@ 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);
return isNavidromeFilterApplied || isJellyfinFilterApplied; const isSubsonicFilterApplied =
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]); server?.type === ServerType.SUBSONIC && (filter?.isFavorite || filter?.genre);
return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied;
}, [
filter._custom?.jellyfin,
filter._custom?.navidrome,
filter?.genre,
filter?.isFavorite,
server?.type,
]);
const isFolderFilterApplied = useMemo(() => { const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined; return filter.musicFolderId !== undefined;
@@ -475,11 +494,15 @@ export const SongListHeaderFilters = ({
))} ))}
</DropdownMenu.Dropdown> </DropdownMenu.Dropdown>
</DropdownMenu> </DropdownMenu>
<Divider orientation="vertical" /> {server?.type !== ServerType.SUBSONIC && (
<OrderToggleButton <>
sortOrder={filter.sortOrder} <Divider orientation="vertical" />
onToggle={handleToggleSortOrder} <OrderToggleButton
/> sortOrder={filter.sortOrder}
onToggle={handleToggleSortOrder}
/>
</>
)}
{server?.type === ServerType.JELLYFIN && ( {server?.type === ServerType.JELLYFIN && (
<> <>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
@@ -0,0 +1,109 @@
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 { 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({ key: pageKey });
const isGenrePage = customFilters?._custom?.navidrome?.genre_id !== 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: {
genre: e || undefined,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
}, 250);
const toggleFilters = [
{
label: t('filter.isFavorited', { postProcess: 'sentenceCase' }),
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const updatedFilters = setFilter({
customFilters,
data: {
isFavorite: e.target.checked,
},
itemType: LibraryItem.SONG,
key: pageKey,
}) as SongListFilter;
onFilterChange(updatedFilters);
},
value: filter.isFavorite,
},
];
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}
size="xs"
onChange={filter.onChange}
/>
</Group>
))}
<Divider my="0.5rem" />
<Group grow>
{!isGenrePage && (
<Select
clearable
searchable
data={genreList}
defaultValue={filter.genre}
label={t('entity.genre', { count: 1, postProcess: 'titleCase' })}
width={150}
onChange={handleGenresFilter}
/>
)}
</Group>
</Stack>
);
};
@@ -11,6 +11,8 @@ export const getSongListCountQuery = (query: SongListQuery) => {
if (query.searchTerm) filter.searchTerm = query.searchTerm; if (query.searchTerm) filter.searchTerm = query.searchTerm;
if (query.genreId) filter.genreId = query.genreId; if (query.genreId) filter.genreId = query.genreId;
if (query.musicFolderId) filter.musicFolderId = query.musicFolderId; if (query.musicFolderId) filter.musicFolderId = query.musicFolderId;
if (query.isFavorite) filter.isFavorite = query.isFavorite;
if (query.genre) filter.genre = query.genre;
if (Object.keys(filter).length === 0) return undefined; if (Object.keys(filter).length === 0) return undefined;
@@ -36,6 +36,7 @@ const TrackListRoute = () => {
genre_id: genreId, genre_id: genreId,
}, },
}, },
genre: genreId,
genreId, genreId,
}), }),
}; };
@@ -15,6 +15,8 @@ interface UseHandleListFilterChangeProps {
server: ServerListItem | null; server: ServerListItem | null;
} }
const BLOCK_SIZE = 500;
export const useListFilterRefresh = ({ export const useListFilterRefresh = ({
server, server,
itemType, itemType,
@@ -108,7 +110,7 @@ export const useListFilterRefresh = ({
} }
if (results.totalRecordCount === null) { if (results.totalRecordCount === null) {
const hasMoreRows = results?.items?.length === filter.limit; const hasMoreRows = results?.items?.length === BLOCK_SIZE;
const lastRowIndex = hasMoreRows const lastRowIndex = hasMoreRows
? undefined ? undefined
: (filter.offset || 0) + results.items.length; : (filter.offset || 0) + results.items.length;