From d6cc6a474599067d2090e880afbc4130577b0172 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 19 Dec 2023 14:58:52 -0800 Subject: [PATCH] Support subsonic song filters --- .../api/subsonic/subsonic-controller.ts | 121 ++++++++++++++++-- .../api/subsonic/subsonic-normalize.ts | 3 +- src/renderer/api/types.ts | 2 + .../virtual-table/hooks/use-virtual-table.ts | 10 +- .../components/song-list-header-filters.tsx | 71 ++++++---- .../components/subsonic-song-filters.tsx | 109 ++++++++++++++++ .../songs/queries/song-list-count-query.ts | 2 + .../features/songs/routes/song-list-route.tsx | 1 + src/renderer/hooks/use-list-filter-refresh.ts | 4 +- 9 files changed, 286 insertions(+), 37 deletions(-) create mode 100644 src/renderer/features/songs/components/subsonic-song-filters.tsx diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 82d3d1540..d56581f8e 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -219,7 +219,7 @@ export const SubsonicController: ControllerEndpoint = { ); let results = artists.map((artist) => - subsonicNormalize.albumArtist(artist, apiClientProps.server), + subsonicNormalize.albumArtist(artist, apiClientProps.server, 300), ); if (query.searchTerm) { @@ -880,11 +880,39 @@ export const SubsonicController: ControllerEndpoint = { const artistDetailPromises = []; 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({ query: { count: query.limit, - genre: query.genreId, + genre: query.genre, musicFolderId: query.musicFolderId, offset: query.startIndex, }, @@ -896,14 +924,39 @@ export const SubsonicController: ControllerEndpoint = { } return { - items: res.body['subsonic-response'].songsByGenre.song.map((song) => - subsonicNormalize.song(song, apiClientProps.server, ''), - ), + items: + res.body['subsonic-response'].songsByGenre.song?.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ) || [], startIndex: 0, 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) { for (const albumId of query.albumIds) { @@ -1009,13 +1062,48 @@ export const SubsonicController: ControllerEndpoint = { let fetchNextSection = true; 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; while (fetchNextSection) { const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ query: { count: 1, - genre: query.genreId, + genre: query.genre, musicFolderId: query.musicFolderId, offset: sectionIndex, }, @@ -1042,7 +1130,7 @@ export const SubsonicController: ControllerEndpoint = { const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ query: { count: 500, - genre: query.genreId, + genre: query.genre, musicFolderId: query.musicFolderId, offset: startIndex, }, @@ -1065,6 +1153,21 @@ export const SubsonicController: ControllerEndpoint = { 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; while (fetchNextSection) { diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index 109eb9ee5..7100826b2 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -38,13 +38,14 @@ const normalizeSong = ( item: z.infer, server: ServerListItem | null, deviceId: string, + size?: number, ): QueueSong => { const imageUrl = getCoverArtUrl({ baseUrl: server?.url, coverArtId: item.coverArt, credential: server?.credential, - size: 100, + size: size || 300, }) || null; const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 72ae1df0b..75ce92769 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -492,8 +492,10 @@ export type SongListQuery = { }; albumIds?: string[]; artistIds?: string[]; + genre?: string; genreId?: string; imageSize?: number; + isFavorite?: boolean; limit?: number; maxYear?: number; minYear?: number; diff --git a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts index 7385f3134..3b1604ced 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -34,6 +34,7 @@ interface UseAgGridProps { columnType?: 'albumDetail' | 'generic'; contextMenu: SetContextMenuItems; customFilters?: Partial; + isClientSide?: boolean; isClientSideSort?: boolean; isSearchParams?: boolean; itemCount?: number; @@ -43,6 +44,8 @@ interface UseAgGridProps { tableRef: MutableRefObject; } +const BLOCK_SIZE = 500; + export const useVirtualTable = ({ server, tableRef, @@ -52,6 +55,7 @@ export const useVirtualTable = ({ itemCount, customFilters, isSearchParams, + isClientSide, isClientSideSort, columnType, }: UseAgGridProps) => { @@ -183,7 +187,7 @@ export const useVirtualTable = ({ } if (results.totalRecordCount === null) { - const hasMoreRows = results?.items?.length === properties.filter.limit; + const hasMoreRows = results?.items?.length === BLOCK_SIZE; const lastRowIndex = hasMoreRows ? undefined : (properties.filter.offset || 0) + results.items.length; @@ -334,6 +338,7 @@ export const useVirtualTable = ({ alwaysShowHorizontalScroll: true, autoFitColumns: properties.table.autoFit, blockLoadDebounceMillis: 200, + cacheBlockSize: 500, getRowId: (data: GetRowIdParams) => data.data.id, infiniteInitialRowCount: itemCount || 100, pagination: isPaginationEnabled, @@ -348,10 +353,11 @@ export const useVirtualTable = ({ : undefined, rowBuffer: 20, rowHeight: properties.table.rowHeight || 40, - rowModelType: 'infinite' as RowModelType, + rowModelType: isClientSide ? 'clientSide' : ('infinite' as RowModelType), suppressRowDrag: true, }; }, [ + isClientSide, isPaginationEnabled, isSearchParams, itemCount, diff --git a/src/renderer/features/songs/components/song-list-header-filters.tsx b/src/renderer/features/songs/components/song-list-header-filters.tsx index 3d814b867..439cc2b7e 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -29,6 +29,7 @@ import { queryClient } from '/@/renderer/lib/react-query'; import { SongListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store'; import { ListDisplayType, Play, ServerType, TableColumn } from '/@/renderer/types'; import i18n from '/@/i18n/i18n'; +import { SubsonicSongFilters } from '/@/renderer/features/songs/components/subsonic-song-filters'; const FILTERS = { jellyfin: [ @@ -400,25 +401,34 @@ export const SongListHeaderFilters = ({ }; 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({ children: ( - <> - {server?.type === ServerType.NAVIDROME ? ( - - ) : ( - - )} - + ), title: 'Song Filters', }); @@ -437,8 +447,17 @@ export const SongListHeaderFilters = ({ .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 isSubsonicFilterApplied = + 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(() => { return filter.musicFolderId !== undefined; @@ -475,11 +494,15 @@ export const SongListHeaderFilters = ({ ))} - - + {server?.type !== ServerType.SUBSONIC && ( + <> + + + + )} {server?.type === ServerType.JELLYFIN && ( <> diff --git a/src/renderer/features/songs/components/subsonic-song-filters.tsx b/src/renderer/features/songs/components/subsonic-song-filters.tsx new file mode 100644 index 000000000..548819da0 --- /dev/null +++ b/src/renderer/features/songs/components/subsonic-song-filters.tsx @@ -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; + 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) => { + const updatedFilters = setFilter({ + customFilters, + data: { + isFavorite: e.target.checked, + }, + itemType: LibraryItem.SONG, + key: pageKey, + }) as SongListFilter; + + onFilterChange(updatedFilters); + }, + value: filter.isFavorite, + }, + ]; + + return ( + + {toggleFilters.map((filter) => ( + + {filter.label} + + + ))} + + + {!isGenrePage && ( +