diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index c9a54c176..82d3d1540 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1,3 +1,4 @@ +import dayjs from 'dayjs'; import filter from 'lodash/filter'; import orderBy from 'lodash/orderBy'; import md5 from 'md5'; @@ -13,7 +14,7 @@ import { LibraryItem, PlaylistListSort, } from '/@/renderer/api/types'; -import { sortAlbumArtistList, sortSongList } from '/@/renderer/api/utils'; +import { sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/renderer/api/utils'; import { randomString } from '/@/renderer/utils'; const authenticate = async ( @@ -292,6 +293,36 @@ export const SubsonicController: ControllerEndpoint = { getAlbumList: async (args) => { const { query, apiClientProps } = args; + if (query.searchTerm) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: query.limit, + albumOffset: query.startIndex, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 0, + songOffset: 0, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + const results = + res.body['subsonic-response'].searchResult3.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ) || []; + + return { + items: results, + startIndex: query.startIndex, + totalRecordCount: null, + }; + } + const sortType: Record = { [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, [AlbumListSort.ALBUM_ARTIST]: @@ -312,13 +343,9 @@ export const SubsonicController: ControllerEndpoint = { [AlbumListSort.SONG_COUNT]: undefined, }; - if (query.isCompilation) { - return { - items: [], - startIndex: 0, - totalRecordCount: 0, - }; - } + let type = + sortType[query.sortBy] ?? + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME; if (query.artistIds) { const promises = []; @@ -351,17 +378,63 @@ export const SubsonicController: ControllerEndpoint = { }; } + if (query.isFavorite) { + const res = await subsonicApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + const results = + res.body['subsonic-response'].starred.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server), + ) || []; + + return { + items: sortAlbumList(results, query.sortBy, query.sortOrder), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].starred.album?.length || 0, + }; + } + + if (query.genre) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_GENRE; + } + + if (query.minYear || query.maxYear) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_YEAR; + } + + let fromYear; + let toYear; + + if (query.minYear) { + fromYear = query.minYear; + toYear = dayjs().year(); + } + + if (query.maxYear) { + toYear = query.maxYear; + + if (!query.minYear) { + fromYear = 0; + } + } + const res = await subsonicApiClient(apiClientProps).getAlbumList2({ query: { - fromYear: query.minYear, + fromYear, genre: query.genre, musicFolderId: query.musicFolderId, offset: query.startIndex, size: query.limit, - toYear: query.maxYear, - type: - sortType[query.sortBy] ?? - SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + toYear, + type, }, }); @@ -371,9 +444,10 @@ export const SubsonicController: ControllerEndpoint = { } return { - items: res.body['subsonic-response'].albumList2.album.map((album) => - subsonicNormalize.album(album, apiClientProps.server), - ), + items: + res.body['subsonic-response'].albumList2.album?.map((album) => + subsonicNormalize.album(album, apiClientProps.server, 300), + ) || [], startIndex: query.startIndex, totalRecordCount: null, }; @@ -381,6 +455,41 @@ export const SubsonicController: ControllerEndpoint = { getAlbumListCount: async (args) => { const { query, apiClientProps } = args; + if (query.searchTerm) { + let fetchNextPage = true; + let startIndex = 0; + let totalRecordCount = 0; + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 500, + albumOffset: startIndex, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 0, + songOffset: 0, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list count'); + throw new Error('Failed to get album list count'); + } + + const albumCount = res.body['subsonic-response'].searchResult3.album?.length; + + totalRecordCount += albumCount; + startIndex += albumCount; + + // The max limit size for Subsonic is 500 + fetchNextPage = albumCount === 500; + } + + return totalRecordCount; + } + const sortType: Record = { [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, [AlbumListSort.ALBUM_ARTIST]: @@ -401,22 +510,63 @@ export const SubsonicController: ControllerEndpoint = { [AlbumListSort.SONG_COUNT]: undefined, }; + if (query.isFavorite) { + const res = await subsonicApiClient(apiClientProps).getStarred({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album list'); + throw new Error('Failed to get album list'); + } + + return res.body['subsonic-response'].starred.album?.length || 0; + } + + let type = + sortType[query.sortBy] ?? + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME; + let fetchNextPage = true; let startIndex = 0; let totalRecordCount = 0; + if (query.genre) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_GENRE; + } + + if (query.minYear || query.maxYear) { + type = SubsonicApi.getAlbumList2.enum.AlbumListSortType.BY_YEAR; + } + + let fromYear; + let toYear; + + if (query.minYear) { + fromYear = query.minYear; + toYear = dayjs().year(); + } + + if (query.maxYear) { + toYear = query.maxYear; + + if (!query.minYear) { + fromYear = 0; + } + } + while (fetchNextPage) { const res = await subsonicApiClient(apiClientProps).getAlbumList2({ query: { - fromYear: query.minYear, + fromYear, genre: query.genre, musicFolderId: query.musicFolderId, offset: startIndex, size: 500, - toYear: query.maxYear, - type: - sortType[query.sortBy] ?? - SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + toYear, + type, }, }); diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index ace3974ac..109eb9ee5 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -153,13 +153,14 @@ const normalizeAlbum = ( | z.infer | z.infer, server: ServerListItem | null, + size?: number, ): Album => { const imageUrl = getCoverArtUrl({ baseUrl: server?.url, coverArtId: item.coverArt, credential: server?.credential, - size: 300, + size: size || 300, }) || null; return { diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 0f12aea77..72ae1df0b 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -378,6 +378,7 @@ export type AlbumListQuery = { artistIds?: string[]; genre?: string; isCompilation?: boolean; + isFavorite?: boolean; limit?: number; maxYear?: number; minYear?: number; diff --git a/src/renderer/api/utils.ts b/src/renderer/api/utils.ts index 335c42589..f3a8915c9 100644 --- a/src/renderer/api/utils.ts +++ b/src/renderer/api/utils.ts @@ -3,8 +3,10 @@ import { toast } from '/@/renderer/components'; import { useAuthStore } from '/@/renderer/store'; import { ServerListItem } from '/@/renderer/types'; import { + Album, AlbumArtist, AlbumArtistListSort, + AlbumListSort, QueueSong, SongListSort, SortOrder, @@ -49,6 +51,56 @@ export const authenticationFailure = (currentServer: ServerListItem | null) => { } }; +export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => { + let results = albums; + + const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; + + switch (sortBy) { + case AlbumListSort.ALBUM_ARTIST: + results = orderBy( + results, + ['albumArtist', (v) => v.name.toLowerCase()], + [order, 'asc'], + ); + break; + case AlbumListSort.DURATION: + results = orderBy(results, ['duration'], [order]); + break; + case AlbumListSort.FAVORITED: + results = orderBy(results, ['starred'], [order]); + break; + case AlbumListSort.NAME: + results = orderBy(results, [(v) => v.name.toLowerCase()], [order]); + break; + case AlbumListSort.PLAY_COUNT: + results = orderBy(results, ['playCount'], [order]); + break; + case AlbumListSort.RANDOM: + results = shuffle(results); + break; + case AlbumListSort.RECENTLY_ADDED: + results = orderBy(results, ['createdAt'], [order]); + break; + case AlbumListSort.RECENTLY_PLAYED: + results = orderBy(results, ['lastPlayedAt'], [order]); + break; + case AlbumListSort.RATING: + results = orderBy(results, ['userRating'], [order]); + break; + case AlbumListSort.YEAR: + results = orderBy(results, ['releaseYear'], [order]); + break; + case AlbumListSort.SONG_COUNT: + results = orderBy(results, ['songCount'], [order]); + break; + default: + break; + } + + return results; +}; + export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder: SortOrder) => { let results = songs; 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 776a6d8c6..7385f3134 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -183,15 +183,14 @@ export const useVirtualTable = ({ } if (results.totalRecordCount === null) { - const totalRecordCount: number | undefined = itemCount; const hasMoreRows = results?.items?.length === properties.filter.limit; const lastRowIndex = hasMoreRows ? undefined - : properties.filter.offset + results.items.length; + : (properties.filter.offset || 0) + results.items.length; params.successCallback( results?.items || [], - totalRecordCount || lastRowIndex, + hasMoreRows ? undefined : lastRowIndex, ); return; } @@ -212,7 +211,6 @@ export const useVirtualTable = ({ queryClient, isClientSideSort, queryFn, - itemCount, ], ); diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index 544115578..9fd4eee71 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -22,6 +22,7 @@ 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'; @@ -233,27 +234,35 @@ export const AlbumListHeaderFilters = ({ ); 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 ? ( - - ) : ( - - )} - + ), title: 'Album Filters', }); @@ -389,8 +398,20 @@ export const AlbumListHeaderFilters = ({ 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.genre || filter.isFavorite); + + return isNavidromeFilterApplied || isJellyfinFilterApplied || isSubsonicFilterApplied; + }, [ + filter?._custom?.jellyfin, + filter?._custom?.navidrome, + filter.genre, + filter.isFavorite, + filter.maxYear, + filter.minYear, + server?.type, + ]); const isFolderFilterApplied = useMemo(() => { return filter.musicFolderId !== undefined; diff --git a/src/renderer/features/albums/components/subsonic-album-filters.tsx b/src/renderer/features/albums/components/subsonic-album-filters.tsx new file mode 100644 index 000000000..976dc42ce --- /dev/null +++ b/src/renderer/features/albums/components/subsonic-album-filters.tsx @@ -0,0 +1,143 @@ +import { Divider, Group, Stack } from '@mantine/core'; +import debounce from 'lodash/debounce'; +import { ChangeEvent, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { 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({ 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: { + genre: e || undefined, + }, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + + onFilterChange(updatedFilters); + }, 250); + + const toggleFilters = [ + { + label: t('filter.isFavorited', { postProcess: 'sentenceCase' }), + onChange: (e: ChangeEvent) => { + const updatedFilters = setFilter({ + data: { + isFavorite: e.target.checked ? true : undefined, + }, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + onFilterChange(updatedFilters); + }, + value: filter.isFavorite, + }, + ]; + + const handleYearFilter = debounce((e: number | string, type: 'min' | 'max') => { + let data = {}; + + if (type === 'min') { + data = { + minYear: e || undefined, + }; + } else { + data = { + maxYear: e || undefined, + }; + } + + console.log('data', data); + + const updatedFilters = setFilter({ + data, + itemType: LibraryItem.ALBUM, + key: pageKey, + }) as AlbumListFilter; + + onFilterChange(updatedFilters); + }, 500); + + return ( + + {toggleFilters.map((filter) => ( + + {filter.label} + + + ))} + + + handleYearFilter(e, 'min')} + /> + handleYearFilter(e, 'max')} + /> + + +