diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index fc3cd1d14..dddd79bb4 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -131,6 +131,15 @@ const getAlbumList = async (args: AlbumListArgs) => { )?.(args); }; +const getAlbumListCount = async (args: AlbumListArgs) => { + return ( + apiController( + 'getAlbumListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getAlbumListCount'] + )?.(args); +}; + const getAlbumDetail = async (args: AlbumDetailArgs) => { return ( apiController( @@ -149,6 +158,15 @@ const getSongList = async (args: SongListArgs) => { )?.(args); }; +const getSongListCount = async (args: SongListArgs) => { + return ( + apiController( + 'getSongListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getSongListCount'] + )?.(args); +}; + const getSongDetail = async (args: SongDetailArgs) => { return ( apiController( @@ -194,6 +212,15 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs) => { )?.(args); }; +const getAlbumArtistListCount = async (args: AlbumArtistListArgs) => { + return ( + apiController( + 'getAlbumArtistListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getAlbumArtistListCount'] + )?.(args); +}; + const getArtistList = async (args: ArtistListArgs) => { return ( apiController( @@ -212,6 +239,15 @@ const getPlaylistList = async (args: PlaylistListArgs) => { )?.(args); }; +const getPlaylistListCount = async (args: PlaylistListArgs) => { + return ( + apiController( + 'getPlaylistListCount', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getPlaylistListCount'] + )?.(args); +}; + const createPlaylist = async (args: CreatePlaylistArgs) => { return ( apiController( @@ -362,18 +398,22 @@ export const controller = { deletePlaylist, getAlbumArtistDetail, getAlbumArtistList, + getAlbumArtistListCount, getAlbumDetail, getAlbumList, + getAlbumListCount, getArtistList, getGenreList, getLyrics, getMusicFolderList, getPlaylistDetail, getPlaylistList, + getPlaylistListCount, getPlaylistSongList, getRandomSongList, getSongDetail, getSongList, + getSongListCount, getTopSongList, getUserList, removeFromPlaylist, diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index dd53aab6d..4112fd8a3 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -480,7 +480,6 @@ const removeFromPlaylist = async ( const { query, apiClientProps } = args; const res = await jfApiClient(apiClientProps).removeFromPlaylist({ - body: null, params: { id: query.id, }, @@ -648,7 +647,6 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise => { const { query, apiClientProps } = args; const res = await jfApiClient(apiClientProps).deletePlaylist({ - body: null, params: { id: query.id, }, diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index 4aafb14e0..d03c0d234 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -11,8 +11,8 @@ import { import { ServerListItem, ServerType } from '/@/renderer/types'; import z from 'zod'; import { ndType } from './navidrome-types'; -import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; import { NDGenre } from '/@/renderer/api/navidrome.types'; +import { SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; const getImageUrl = (args: { url: string | null }) => { const { url } = args; @@ -186,7 +186,9 @@ const normalizeAlbum = ( const normalizeAlbumArtist = ( item: z.infer & { - similarArtists?: z.infer['artistInfo']['similarArtist']; + similarArtists?: z.infer< + typeof SubsonicApi.getArtistInfo2.response + >['subsonic-response']['artistInfo2']['similarArtist']; }, server: ServerListItem | null, ): AlbumArtist => { diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 30433e461..fa6b96b0b 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -49,6 +49,19 @@ export const queryKeys: Record< Record QueryFunctionContext['queryKey']> > = { albumArtists: { + count: (serverId: string, query?: AlbumArtistListQuery) => { + const { pagination, filter } = splitPaginatedQuery(query); + + if (query && pagination) { + return [serverId, 'albumArtists', 'count', filter, pagination] as const; + } + + if (query) { + return [serverId, 'albumArtists', 'count', filter] as const; + } + + return [serverId, 'albumArtists', 'count'] as const; + }, detail: (serverId: string, query?: AlbumArtistDetailQuery) => { if (query) return [serverId, 'albumArtists', 'detail', query] as const; return [serverId, 'albumArtists', 'detail'] as const; @@ -72,23 +85,40 @@ export const queryKeys: Record< }, }, albums: { - detail: (serverId: string, query?: AlbumDetailQuery) => - [serverId, 'albums', 'detail', query] as const, - list: (serverId: string, query?: AlbumListQuery, artistId?: string) => { + count: (serverId: string, query?: AlbumListQuery, artistId?: string) => { const { pagination, filter } = splitPaginatedQuery(query); if (query && pagination && artistId) { - return [serverId, 'albums', 'list', artistId, filter, pagination] as const; + return [serverId, 'albums', 'count', artistId, filter, pagination] as const; } if (query && pagination) { - return [serverId, 'albums', 'list', filter, pagination] as const; + return [serverId, 'albums', 'count', filter, pagination] as const; } if (query && artistId) { - return [serverId, 'albums', 'list', artistId, filter] as const; + return [serverId, 'albums', 'count', artistId, filter] as const; } + if (query) { + return [serverId, 'albums', 'count', filter] as const; + } + + return [serverId, 'albums', 'count'] as const; + }, + detail: (serverId: string, query?: AlbumDetailQuery) => + [serverId, 'albums', 'detail', query] as const, + list: ( + serverId: string, + query?: { + artistIds?: string[]; + maxYear?: number; + minYear?: number; + searchTerm?: string; + }, + ) => { + const { filter } = splitPaginatedQuery(query); + if (query) { return [serverId, 'albums', 'list', filter] as const; } @@ -207,6 +237,19 @@ export const queryKeys: Record< root: (serverId: string) => [serverId] as const, }, songs: { + count: (serverId: string, query?: AlbumArtistListQuery) => { + const { pagination, filter } = splitPaginatedQuery(query); + + if (query && pagination) { + return [serverId, 'songs', 'count', filter, pagination] as const; + } + + if (query) { + return [serverId, 'songs', 'count', filter] as const; + } + + return [serverId, 'songs', 'count'] as const; + }, detail: (serverId: string, query?: SongDetailQuery) => { if (query) return [serverId, 'songs', 'detail', query] as const; return [serverId, 'songs', 'detail'] as const; diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index fa5ad2c18..4d2eb1036 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -435,16 +435,21 @@ axiosClient.interceptors.response.use( (response) => { const data = response.data; - if (data['subsonic-response'].status !== 'ok') { + // Ping endpoint returns a string + if (typeof data === 'string') { + return response; + } + + if (data['subsonic-response']?.status !== 'ok') { // Suppress code related to non-linked lastfm or spotify from Navidrome - if (data['subsonic-response'].error.code !== 0) { + if (data['subsonic-response']?.error.code !== 0) { toast.error({ - message: data['subsonic-response'].error.message, + message: data['subsonic-response']?.error.message, title: i18n.t('error.genericError', { postProcess: 'sentenceCase' }) as string, }); } - return Promise.reject(data['subsonic-response'].error); + return Promise.reject(data['subsonic-response']?.error); } return response; @@ -513,9 +518,9 @@ export const subsonicApiClient = (args: { }); return { - body: result.data, - headers: result.headers as any, - status: result.status, + body: result?.data, + headers: result?.headers as any, + status: result?.status, }; } catch (e: Error | AxiosError | any) { if (isAxiosError(e)) { diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index ab82df47d..a7f1a10ab 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1,15 +1,20 @@ +import orderBy from 'lodash/orderBy'; +import filter from 'lodash/filter'; import md5 from 'md5'; +import { fsLog } from '/@/logger'; import { subsonicApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { subsonicNormalize } from '/@/renderer/api/subsonic/subsonic-normalize'; import { AlbumListSortType, SubsonicApi } from '/@/renderer/api/subsonic/subsonic-types'; import { + AlbumArtistListSort, AlbumListSort, AuthenticationResponse, ControllerEndpoint, + GenreListSort, LibraryItem, + PlaylistListSort, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; -import { fsLog } from '/@/logger'; const authenticate = async ( url: string, @@ -184,7 +189,7 @@ export const SubsonicController: ControllerEndpoint = { } return { - ...subsonicNormalize.albumArtist(artist, apiClientProps.server), + ...subsonicNormalize.albumArtist(artist, apiClientProps.server, 300), albums: artist.album.map((album) => subsonicNormalize.album(album, apiClientProps.server), ), @@ -193,6 +198,7 @@ export const SubsonicController: ControllerEndpoint = { }, getAlbumArtistList: async (args) => { const { query, apiClientProps } = args; + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const res = await subsonicApiClient(apiClientProps).getArtists({ query: { @@ -209,14 +215,79 @@ export const SubsonicController: ControllerEndpoint = { (index) => index.artist, ); + let results = artists; + let totalRecordCount = artists.length; + + if (query.searchTerm) { + const searchResults = filter(results, (artist) => { + return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + totalRecordCount = searchResults.length; + } + + switch (query.sortBy) { + case AlbumArtistListSort.ALBUM_COUNT: + results = orderBy( + artists, + ['albumCount', (v) => v.name.toLowerCase()], + [sortOrder, 'asc'], + ); + break; + case AlbumArtistListSort.NAME: + results = orderBy(artists, [(v) => v.name.toLowerCase()], [sortOrder]); + break; + case AlbumArtistListSort.FAVORITED: + results = orderBy(artists, ['starred'], [sortOrder]); + break; + case AlbumArtistListSort.RATING: + results = orderBy(artists, ['userRating'], [sortOrder]); + break; + default: + break; + } + return { - items: artists.map((artist) => + items: results.map((artist) => subsonicNormalize.albumArtist(artist, apiClientProps.server), ), startIndex: query.startIndex, - totalRecordCount: null, + totalRecordCount, }; }, + getAlbumArtistListCount: async (args) => { + const { query, apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getArtists({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get album artist list count'); + throw new Error('Failed to get album artist list count'); + } + + const artists = (res.body['subsonic-response'].artists?.index || []).flatMap( + (index) => index.artist, + ); + + let results = artists; + let totalRecordCount = artists.length; + + if (query.searchTerm) { + const searchResults = filter(results, (artist) => { + return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + totalRecordCount = searchResults.length; + } + + return totalRecordCount; + }, getAlbumDetail: async (args) => { const { query, apiClientProps } = args; @@ -285,6 +356,73 @@ export const SubsonicController: ControllerEndpoint = { totalRecordCount: null, }; }, + getAlbumListCount: async (args) => { + const { query, apiClientProps } = args; + + const sortType: Record = { + [AlbumListSort.RANDOM]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RANDOM, + [AlbumListSort.ALBUM_ARTIST]: + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_ARTIST, + [AlbumListSort.PLAY_COUNT]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.FREQUENT, + [AlbumListSort.RECENTLY_ADDED]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.NEWEST, + [AlbumListSort.FAVORITED]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.STARRED, + [AlbumListSort.YEAR]: SubsonicApi.getAlbumList2.enum.AlbumListSortType.RECENT, + [AlbumListSort.NAME]: + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + [AlbumListSort.COMMUNITY_RATING]: undefined, + [AlbumListSort.DURATION]: undefined, + [AlbumListSort.CRITIC_RATING]: undefined, + [AlbumListSort.RATING]: undefined, + [AlbumListSort.ARTIST]: undefined, + [AlbumListSort.RECENTLY_PLAYED]: undefined, + [AlbumListSort.RELEASE_DATE]: undefined, + [AlbumListSort.SONG_COUNT]: undefined, + }; + + let fetchNextPage = true; + let startIndex = 0; + let totalRecordCount = 0; + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).getAlbumList2({ + query: { + fromYear: query.minYear, + genre: query.genre, + musicFolderId: query.musicFolderId, + offset: startIndex, + size: 500, + toYear: query.maxYear, + type: + sortType[query.sortBy] ?? + SubsonicApi.getAlbumList2.enum.AlbumListSortType.ALPHABETICAL_BY_NAME, + }, + }); + + const headers = res.headers; + + // Navidrome returns the total count in the header + if (headers.get('x-total-count')) { + fetchNextPage = false; + totalRecordCount = Number(headers.get('x-total-count')); + break; + } + + 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'].albumList2.album.length; + + totalRecordCount += albumCount; + startIndex += albumCount; + + // The max limit size for Subsonic is 500 + fetchNextPage = albumCount === 500; + } + + return totalRecordCount; + }, getAlbumSongList: async (args) => { const { query, apiClientProps } = args; @@ -326,7 +464,8 @@ export const SubsonicController: ControllerEndpoint = { return res.body['subsonic-response'].artistInfo; }, getGenreList: async (args) => { - const { apiClientProps } = args; + const { query, apiClientProps } = args; + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; const res = await subsonicApiClient(apiClientProps).getGenres({}); @@ -335,7 +474,31 @@ export const SubsonicController: ControllerEndpoint = { throw new Error('Failed to get genre list'); } - const genres = res.body['subsonic-response'].genres.genre.map(subsonicNormalize.genre); + let results = res.body['subsonic-response'].genres.genre; + + if (query.searchTerm) { + const searchResults = filter(results, (genre) => + genre.value.toLowerCase().includes(query.searchTerm!.toLowerCase()), + ); + + results = searchResults; + } + + switch (query.sortBy) { + case GenreListSort.NAME: + results = orderBy(results, [(v) => v.value.toLowerCase()], [sortOrder]); + break; + case GenreListSort.ALBUM_COUNT: + results = orderBy(results, ['albumCount'], [sortOrder]); + break; + case GenreListSort.SONG_COUNT: + results = orderBy(results, ['songCount'], [sortOrder]); + break; + default: + break; + } + + const genres = results.map(subsonicNormalize.genre); return { items: genres, @@ -361,6 +524,70 @@ export const SubsonicController: ControllerEndpoint = { totalRecordCount: res.body['subsonic-response'].musicFolders.musicFolder.length, }; }, + getPlaylistList: async (args) => { + const { query, apiClientProps } = args; + const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; + + const res = await subsonicApiClient(apiClientProps).getPlaylists({}); + + if (res.status !== 200) { + fsLog.error('Failed to get playlist list'); + throw new Error('Failed to get playlist list'); + } + + let results = res.body['subsonic-response'].playlists.playlist; + + if (query.searchTerm) { + const searchResults = filter(results, (playlist) => { + return playlist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + } + + switch (query.sortBy) { + case PlaylistListSort.DURATION: + results = orderBy(results, ['duration'], [sortOrder]); + break; + case PlaylistListSort.NAME: + results = orderBy(results, [(v) => v.name?.toLowerCase()], [sortOrder]); + break; + case PlaylistListSort.OWNER: + results = orderBy(results, [(v) => v.owner?.toLowerCase()], [sortOrder]); + break; + case PlaylistListSort.PUBLIC: + results = orderBy(results, ['public'], [sortOrder]); + break; + case PlaylistListSort.SONG_COUNT: + results = orderBy(results, ['songCount'], [sortOrder]); + break; + case PlaylistListSort.UPDATED_AT: + results = orderBy(results, ['changed'], [sortOrder]); + break; + default: + break; + } + + return { + items: results.map((playlist) => + subsonicNormalize.playlist(playlist, apiClientProps.server), + ), + startIndex: 0, + totalRecordCount: res.body['subsonic-response'].playlists.playlist.length, + }; + }, + getPlaylistListCount: async (args) => { + const { apiClientProps } = args; + + const res = await subsonicApiClient(apiClientProps).getPlaylists({}); + + if (res.status !== 200) { + fsLog.error('Failed to get playlist list count'); + throw new Error('Failed to get playlist list count'); + } + + return res.body['subsonic-response'].playlists.playlist.length; + }, getRandomSongList: async (args) => { const { query, apiClientProps } = args; @@ -407,6 +634,259 @@ export const SubsonicController: ControllerEndpoint = { '', ); }, + getSongList: async (args) => { + const { query, apiClientProps } = args; + + const fromAlbumPromises = []; + const artistDetailPromises = []; + let results: any[] = []; + + if (query.genreId) { + const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ + query: { + count: query.limit, + genre: query.genreId, + musicFolderId: query.musicFolderId, + offset: 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'].songsByGenre.song.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + startIndex: 0, + totalRecordCount: null, + }; + } + + if (query.albumIds || query.artistIds) { + if (query.albumIds) { + for (const albumId of query.albumIds) { + fromAlbumPromises.push( + subsonicApiClient(apiClientProps).getAlbum({ + query: { + id: albumId, + }, + }), + ); + } + } + + if (query.artistIds) { + for (const artistId of query.artistIds) { + artistDetailPromises.push( + subsonicApiClient(apiClientProps).getArtist({ + query: { + id: artistId, + }, + }), + ); + } + + const artistResult = await Promise.all(artistDetailPromises); + + const albums = artistResult.flatMap((artist) => { + if (artist.status !== 200) { + fsLog.warn('Failed to get artist detail', { context: { artist } }); + return []; + } + + return artist.body['subsonic-response'].artist.album; + }); + + const albumIds = albums.map((album) => album.id); + + for (const albumId of albumIds) { + fromAlbumPromises.push( + subsonicApiClient(apiClientProps).getAlbum({ + query: { + id: albumId, + }, + }), + ); + } + } + + if (fromAlbumPromises) { + const albumsResult = await Promise.all(fromAlbumPromises); + + results = albumsResult.flatMap((album) => { + if (album.status !== 200) { + fsLog.warn('Failed to get album detail', { context: { album } }); + return []; + } + + return album.body['subsonic-response'].album.song; + }); + } + + return { + items: results.map((song) => + subsonicNormalize.song(song, apiClientProps.server, ''), + ), + startIndex: 0, + totalRecordCount: results.length, + }; + } + + 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: 0, + totalRecordCount: null, + }; + }, + getSongListCount: async (args) => { + const { query, apiClientProps } = args; + + let fetchNextPage = true; + let startIndex = 0; + + let fetchNextSection = true; + let sectionIndex = 0; + + if (query.genreId) { + let totalRecordCount = 0; + while (fetchNextSection) { + const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ + query: { + count: 1, + genre: query.genreId, + musicFolderId: query.musicFolderId, + offset: sectionIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = + res.body['subsonic-response'].songsByGenre.song?.length || 0; + + if (numberOfResults !== 1) { + fetchNextSection = false; + startIndex = sectionIndex === 0 ? 0 : sectionIndex - 5000; + break; + } else { + sectionIndex += 5000; + } + } + + while (fetchNextPage) { + const res = await subsonicApiClient(apiClientProps).getSongsByGenre({ + query: { + count: 500, + genre: query.genreId, + musicFolderId: query.musicFolderId, + offset: startIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = + res.body['subsonic-response'].songsByGenre.song?.length || 0; + + totalRecordCount = startIndex + numberOfResults; + startIndex += numberOfResults; + + fetchNextPage = numberOfResults === 500; + } + + return totalRecordCount; + } + + let totalRecordCount = 0; + + while (fetchNextSection) { + const res = await subsonicApiClient(apiClientProps).search3({ + query: { + albumCount: 0, + albumOffset: 0, + artistCount: 0, + artistOffset: 0, + query: query.searchTerm || '""', + songCount: 1, + songOffset: sectionIndex, + }, + }); + + if (res.status !== 200) { + fsLog.error('Failed to get song list count'); + throw new Error('Failed to get song list count'); + } + + const numberOfResults = res.body['subsonic-response'].searchResult3.song?.length || 0; + + // Check each batch of 5000 songs to check for data + sectionIndex += 5000; + fetchNextSection = numberOfResults === 1; + + if (!fetchNextSection) { + // fetchNextBlock will be false on the next loop so we need to subtract 5000 * 2 + startIndex = sectionIndex - 10000; + } + } + + 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 numberOfResults = res.body['subsonic-response'].searchResult3.song?.length || 0; + + totalRecordCount = startIndex + numberOfResults; + startIndex += numberOfResults; + + // The max limit size for Subsonic is 500 + fetchNextPage = numberOfResults === 500; + } + + return totalRecordCount; + }, getTopSongs: async (args) => { const { query, apiClientProps } = args; diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index 9c3b8e381..ace3974ac 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -8,6 +8,7 @@ import { Album, Genre, MusicFolder, + Playlist, } from '/@/renderer/api/types'; import { ServerListItem, ServerType } from '/@/renderer/types'; @@ -116,13 +117,14 @@ const normalizeAlbumArtist = ( | z.infer | z.infer, server: ServerListItem | null, + imageSize?: number, ): AlbumArtist => { const imageUrl = getCoverArtUrl({ baseUrl: server?.url, coverArtId: item.coverArt, credential: server?.credential, - size: 100, + size: imageSize || 100, }) || null; return { @@ -167,7 +169,7 @@ const normalizeAlbum = ( artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [], backdropImageUrl: null, createdAt: item.created, - duration: item.duration, + duration: item.duration * 1000, genres: item.genre ? [ { @@ -192,7 +194,10 @@ const normalizeAlbum = ( serverType: ServerType.SUBSONIC, size: null, songCount: item.songCount, - songs: [], + songs: + (item as z.infer).song?.map((song) => + normalizeSong(song, server, ''), + ) || [], uniqueId: nanoid(), updatedAt: item.created, userFavorite: item.starred || false, @@ -220,10 +225,41 @@ const normalizeMusicFolder = ( }; }; +const normalizePlaylist = ( + item: + | z.infer + | z.infer, + server: ServerListItem | null, +): Playlist => { + return { + description: item.comment || null, + duration: item.duration, + genres: [], + id: item.id, + imagePlaceholderUrl: null, + imageUrl: getCoverArtUrl({ + baseUrl: server?.url, + coverArtId: item.coverArt, + credential: server?.credential, + size: 300, + }), + itemType: LibraryItem.PLAYLIST, + name: item.name, + owner: item.owner, + ownerId: item.owner, + public: item.public, + serverId: server?.id || 'unknown', + serverType: ServerType.SUBSONIC, + size: null, + songCount: item.songCount, + }; +}; + export const subsonicNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, genre: normalizeGenre, musicFolder: normalizeMusicFolder, + playlist: normalizePlaylist, song: normalizeSong, }; diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 737477509..c8e825b1b 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -582,7 +582,7 @@ const search3 = { artistCount: z.number().optional(), artistOffset: z.number().optional(), musicFolderId: z.string().optional(), - query: z.string(), + query: z.string().or(z.literal('""')), songCount: z.number().optional(), songOffset: z.number().optional(), }), diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 6b62f033e..153bb928b 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -306,7 +306,9 @@ export type GenreListResponse = BasePaginatedResponse | null | undefine export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs; export enum GenreListSort { + ALBUM_COUNT = 'albumCount', NAME = 'name', + SONG_COUNT = 'songCount', } export type GenreListQuery = { @@ -330,10 +332,14 @@ type GenreListSortMap = { export const genreListSortMap: GenreListSortMap = { jellyfin: { + albumCount: undefined, name: JFGenreListSort.NAME, + songCount: undefined, }, navidrome: { + albumCount: undefined, name: NDGenreListSort.NAME, + songCount: undefined, }, subsonic: { name: undefined, @@ -484,8 +490,11 @@ export type SongListQuery = { }; albumIds?: string[]; artistIds?: string[]; + genreId?: string; imageSize?: number; limit?: number; + maxYear?: number; + minYear?: number; musicFolderId?: string; searchTerm?: string; sortBy: SongListSort; @@ -1161,8 +1170,10 @@ export type ControllerEndpoint = Partial<{ deletePlaylist: (args: DeletePlaylistArgs) => Promise; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise; getAlbumArtistList: (args: AlbumArtistListArgs) => Promise; + getAlbumArtistListCount: (args: AlbumArtistListArgs) => Promise; getAlbumDetail: (args: AlbumDetailArgs) => Promise; getAlbumList: (args: AlbumListArgs) => Promise; + getAlbumListCount: (args: AlbumListArgs) => Promise; getAlbumSongList: (args: AlbumDetailArgs) => Promise; // TODO getArtistDetail: () => void; getArtistInfo: (args: any) => void; @@ -1176,10 +1187,12 @@ export type ControllerEndpoint = Partial<{ getMusicFolderList: (args: MusicFolderListArgs) => Promise; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; getPlaylistList: (args: PlaylistListArgs) => Promise; + getPlaylistListCount: (args: PlaylistListArgs) => Promise; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; getRandomSongList: (args: RandomSongListArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; + getSongListCount: (args: SongListArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; getUserList: (args: UserListArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; 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 1e7221cd1..a99cdb295 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -182,6 +182,20 @@ export const useVirtualTable = ({ return; } + 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; + + params.successCallback( + results?.items || [], + totalRecordCount || lastRowIndex, + ); + return; + } + params.successCallback(results?.items || [], results?.totalRecordCount || 0); }, rowCount: undefined, @@ -198,6 +212,7 @@ 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 268458b74..544115578 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -139,14 +139,61 @@ 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; + itemCount: number | undefined; tableRef: MutableRefObject; } -export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFiltersProps) => { +export const AlbumListHeaderFilters = ({ + gridRef, + tableRef, + itemCount, +}: AlbumListHeaderFiltersProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); const { pageKey, customFilters, handlePlay } = useListContext(); @@ -159,6 +206,7 @@ export const AlbumListHeaderFilters = ({ gridRef, tableRef }: AlbumListHeaderFil const cq = useContainerQuery(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.ALBUM, server, }); diff --git a/src/renderer/features/albums/components/album-list-header.tsx b/src/renderer/features/albums/components/album-list-header.tsx index 657802b0c..b301468b6 100644 --- a/src/renderer/features/albums/components/album-list-header.tsx +++ b/src/renderer/features/albums/components/album-list-header.tsx @@ -38,6 +38,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi const playButtonBehavior = usePlayButtonBehavior(); const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.ALBUM, server, }); @@ -94,6 +95,7 @@ export const AlbumListHeader = ({ itemCount, gridRef, tableRef, title }: AlbumLi diff --git a/src/renderer/features/albums/queries/album-list-count-query.ts b/src/renderer/features/albums/queries/album-list-count-query.ts new file mode 100644 index 000000000..218a67afd --- /dev/null +++ b/src/renderer/features/albums/queries/album-list-count-query.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import type { AlbumListQuery } from '/@/renderer/api/types'; +import type { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { getServerById } from '/@/renderer/store'; + +export const getAlbumListCountQuery = (query: AlbumListQuery) => { + const filter: Record = {}; + + if (query.artistIds) filter.artistIds = query.artistIds; + if (query.maxYear) filter.maxYear = query.maxYear; + if (query.minYear) filter.minYear = query.minYear; + if (query.searchTerm) filter.searchTerm = query.searchTerm; + if (query.musicFolderId) filter.musicFolderId = query.musicFolderId; + + if (Object.keys(filter).length === 0) return undefined; + + return filter; +}; + +export const useAlbumListCount = (args: QueryHookArgs) => { + 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.getAlbumListCount({ + apiClientProps: { + server, + signal, + }, + query, + }); + }, + queryKey: queryKeys.albums.count(serverId || '', getAlbumListCountQuery(query)), + ...options, + }); +}; diff --git a/src/renderer/features/albums/routes/album-list-route.tsx b/src/renderer/features/albums/routes/album-list-route.tsx index b05a15deb..30fcaa2e5 100644 --- a/src/renderer/features/albums/routes/album-list-route.tsx +++ b/src/renderer/features/albums/routes/album-list-route.tsx @@ -9,12 +9,12 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { ListContext } from '/@/renderer/context/list-context'; import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content'; import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header'; -import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query'; import { usePlayQueueAdd } from '/@/renderer/features/player'; import { AnimatedPage } from '/@/renderer/features/shared'; import { queryClient } from '/@/renderer/lib/react-query'; import { useCurrentServer, useListFilterByKey } from '/@/renderer/store'; import { Play } from '/@/renderer/types'; +import { useAlbumListCount } from '/@/renderer/features/albums/queries/album-list-count-query'; const AlbumListRoute = () => { const gridRef = useRef(null); @@ -42,23 +42,18 @@ const AlbumListRoute = () => { key: pageKey, }); - const itemCountCheck = useAlbumList({ + const itemCountCheck = useAlbumListCount({ options: { cacheTime: 1000 * 60, staleTime: 1000 * 60, }, query: { - limit: 1, - startIndex: 0, ...albumListFilter, }, 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 }) => { diff --git a/src/renderer/features/artists/components/album-artist-list-header-filters.tsx b/src/renderer/features/artists/components/album-artist-list-header-filters.tsx index 5d93d8620..003cc83a0 100644 --- a/src/renderer/features/artists/components/album-artist-list-header-filters.tsx +++ b/src/renderer/features/artists/components/album-artist-list-header-filters.tsx @@ -85,6 +85,28 @@ const FILTERS = { value: AlbumArtistListSort.SONG_COUNT, }, ], + subsonic: [ + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }), + value: AlbumArtistListSort.ALBUM_COUNT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }), + value: AlbumArtistListSort.FAVORITED, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: AlbumArtistListSort.NAME, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.rating', { postProcess: 'titleCase' }), + value: AlbumArtistListSort.RATING, + }, + ], }; interface AlbumArtistListHeaderFiltersProps { diff --git a/src/renderer/features/artists/components/album-artist-list-header.tsx b/src/renderer/features/artists/components/album-artist-list-header.tsx index b2048cab6..27f5b7483 100644 --- a/src/renderer/features/artists/components/album-artist-list-header.tsx +++ b/src/renderer/features/artists/components/album-artist-list-header.tsx @@ -35,6 +35,7 @@ export const AlbumArtistListHeader = ({ const cq = useContainerQuery(); const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.ALBUM_ARTIST, server, }); diff --git a/src/renderer/features/artists/queries/album-artist-list-count-query.ts b/src/renderer/features/artists/queries/album-artist-list-count-query.ts new file mode 100644 index 000000000..6330dd0c8 --- /dev/null +++ b/src/renderer/features/artists/queries/album-artist-list-count-query.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { AlbumArtistListQuery } from '/@/renderer/api/types'; +import { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { getServerById } from '/@/renderer/store'; + +export const getAlbumArtistListCountQuery = (query: AlbumArtistListQuery) => { + const filter: Record = {}; + + if (query.searchTerm) filter.searchTerm = query.searchTerm; + if (query.musicFolderId) filter.musicFolderId = query.musicFolderId; + + if (Object.keys(filter).length === 0) return undefined; + + return filter; +}; + +export const useAlbumArtistListCount = (args: QueryHookArgs) => { + 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.getAlbumArtistListCount({ + apiClientProps: { + server, + signal, + }, + query, + }); + }, + queryKey: queryKeys.albumArtists.count(serverId || '', getAlbumArtistListCountQuery(query)), + ...options, + }); +}; diff --git a/src/renderer/features/artists/routes/album-artist-list-route.tsx b/src/renderer/features/artists/routes/album-artist-list-route.tsx index 0f68bd57d..83fc4acec 100644 --- a/src/renderer/features/artists/routes/album-artist-list-route.tsx +++ b/src/renderer/features/artists/routes/album-artist-list-route.tsx @@ -7,7 +7,7 @@ import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { ListContext } from '/@/renderer/context/list-context'; import { AlbumArtistListContent } from '/@/renderer/features/artists/components/album-artist-list-content'; import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header'; -import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query'; +import { useAlbumArtistListCount } from '/@/renderer/features/artists/queries/album-artist-list-count-query'; import { AnimatedPage } from '/@/renderer/features/shared'; const AlbumArtistListRoute = () => { @@ -18,23 +18,18 @@ const AlbumArtistListRoute = () => { const albumArtistListFilter = useListFilterByKey({ key: pageKey }); - const itemCountCheck = useAlbumArtistList({ + const itemCountCheck = useAlbumArtistListCount({ options: { cacheTime: 1000 * 60, staleTime: 1000 * 60, }, query: { - limit: 1, - startIndex: 0, ...albumArtistListFilter, }, serverId: server?.id, }); - const itemCount = - itemCountCheck.data?.totalRecordCount === null - ? undefined - : itemCountCheck.data?.totalRecordCount; + const itemCount = itemCountCheck.data === null ? undefined : itemCountCheck.data; const providerValue = useMemo(() => { return { diff --git a/src/renderer/features/genres/components/genre-list-header-filters.tsx b/src/renderer/features/genres/components/genre-list-header-filters.tsx index 1bf92863d..2353f71a7 100644 --- a/src/renderer/features/genres/components/genre-list-header-filters.tsx +++ b/src/renderer/features/genres/components/genre-list-header-filters.tsx @@ -37,14 +37,36 @@ const FILTERS = { value: GenreListSort.NAME, }, ], + subsonic: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: GenreListSort.NAME, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }), + value: GenreListSort.ALBUM_COUNT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), + value: GenreListSort.SONG_COUNT, + }, + ], }; interface GenreListHeaderFiltersProps { gridRef: MutableRefObject; + itemCount: number | undefined; tableRef: MutableRefObject; } -export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFiltersProps) => { +export const GenreListHeaderFilters = ({ + gridRef, + tableRef, + itemCount, +}: GenreListHeaderFiltersProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); const { pageKey, customFilters } = useListContext(); @@ -54,6 +76,7 @@ export const GenreListHeaderFilters = ({ gridRef, tableRef }: GenreListHeaderFil const cq = useContainerQuery(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.GENRE, server, }); diff --git a/src/renderer/features/genres/components/genre-list-header.tsx b/src/renderer/features/genres/components/genre-list-header.tsx index 8d9176734..f9e22476e 100644 --- a/src/renderer/features/genres/components/genre-list-header.tsx +++ b/src/renderer/features/genres/components/genre-list-header.tsx @@ -34,6 +34,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade const { setFilter, setTablePagination } = useListStoreActions(); const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.GENRE, server, }); @@ -89,6 +90,7 @@ export const GenreListHeader = ({ itemCount, gridRef, tableRef }: GenreListHeade diff --git a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx index cb0c51c38..a8481b2da 100644 --- a/src/renderer/features/playlists/components/playlist-list-header-filters.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header-filters.tsx @@ -69,6 +69,38 @@ const FILTERS = { value: PlaylistListSort.UPDATED_AT, }, ], + subsonic: [ + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.duration', { postProcess: 'titleCase' }), + value: PlaylistListSort.DURATION, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: PlaylistListSort.NAME, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.owner', { postProcess: 'titleCase' }), + value: PlaylistListSort.OWNER, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.isPublic', { postProcess: 'titleCase' }), + value: PlaylistListSort.PUBLIC, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), + value: PlaylistListSort.SONG_COUNT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyUpdated', { postProcess: 'titleCase' }), + value: PlaylistListSort.UPDATED_AT, + }, + ], }; interface PlaylistListHeaderFiltersProps { diff --git a/src/renderer/features/playlists/components/playlist-list-header.tsx b/src/renderer/features/playlists/components/playlist-list-header.tsx index 15fc3a3d6..9f3c9e25a 100644 --- a/src/renderer/features/playlists/components/playlist-list-header.tsx +++ b/src/renderer/features/playlists/components/playlist-list-header.tsx @@ -44,6 +44,7 @@ export const PlaylistListHeader = ({ itemCount, tableRef, gridRef }: PlaylistLis }; const { handleRefreshGrid, handleRefreshTable } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.PLAYLIST, server, }); 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 97a6365be..3d814b867 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -160,14 +160,26 @@ const FILTERS = { value: SongListSort.YEAR, }, ], + subsonic: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: SongListSort.NAME, + }, + ], }; interface SongListHeaderFiltersProps { gridRef: MutableRefObject; + itemCount: number | undefined; tableRef: MutableRefObject; } -export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFiltersProps) => { +export const SongListHeaderFilters = ({ + gridRef, + tableRef, + itemCount, +}: SongListHeaderFiltersProps) => { const { t } = useTranslation(); const server = useCurrentServer(); const { pageKey, handlePlay, customFilters } = useListContext(); @@ -179,6 +191,7 @@ export const SongListHeaderFilters = ({ gridRef, tableRef }: SongListHeaderFilte useListStoreActions(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.SONG, server, }); diff --git a/src/renderer/features/songs/components/song-list-header.tsx b/src/renderer/features/songs/components/song-list-header.tsx index b17b86ecd..90e54c72b 100644 --- a/src/renderer/features/songs/components/song-list-header.tsx +++ b/src/renderer/features/songs/components/song-list-header.tsx @@ -32,6 +32,7 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList const cq = useContainerQuery(); const { handleRefreshTable, handleRefreshGrid } = useListFilterRefresh({ + itemCount, itemType: LibraryItem.SONG, server, }); @@ -94,6 +95,7 @@ export const SongListHeader = ({ gridRef, title, itemCount, tableRef }: SongList diff --git a/src/renderer/features/songs/queries/song-list-count-query.ts b/src/renderer/features/songs/queries/song-list-count-query.ts new file mode 100644 index 000000000..fe21813ab --- /dev/null +++ b/src/renderer/features/songs/queries/song-list-count-query.ts @@ -0,0 +1,39 @@ +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 getSongListCountQuery = (query: SongListQuery) => { + const filter: Record = {}; + + if (query.searchTerm) filter.searchTerm = query.searchTerm; + if (query.genreId) filter.genreId = query.genreId; + if (query.musicFolderId) filter.musicFolderId = query.musicFolderId; + + if (Object.keys(filter).length === 0) return undefined; + + return filter; +}; + +export const useSongListCount = (args: QueryHookArgs) => { + 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 || '', getSongListCountQuery(query)), + ...options, + }); +}; diff --git a/src/renderer/features/songs/routes/song-list-route.tsx b/src/renderer/features/songs/routes/song-list-route.tsx index 61a8f070c..3fc974aba 100644 --- a/src/renderer/features/songs/routes/song-list-route.tsx +++ b/src/renderer/features/songs/routes/song-list-route.tsx @@ -9,11 +9,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 gridRef = useRef(null); @@ -36,6 +36,7 @@ const TrackListRoute = () => { genre_id: genreId, }, }, + genreId, }), }; @@ -74,7 +75,7 @@ const TrackListRoute = () => { return genre?.name; }, [genreId, genreList.data]); - const itemCountCheck = useSongList({ + const itemCountCheck = useSongListCount({ options: { cacheTime: 1000 * 60, staleTime: 1000 * 60, @@ -87,10 +88,7 @@ const TrackListRoute = () => { 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 }) => { diff --git a/src/renderer/hooks/use-list-filter-refresh.ts b/src/renderer/hooks/use-list-filter-refresh.ts index 91ccb000b..d34dd4ff0 100644 --- a/src/renderer/hooks/use-list-filter-refresh.ts +++ b/src/renderer/hooks/use-list-filter-refresh.ts @@ -10,6 +10,7 @@ import orderBy from 'lodash/orderBy'; interface UseHandleListFilterChangeProps { isClientSideSort?: boolean; + itemCount?: number; itemType: LibraryItem; server: ServerListItem | null; } @@ -18,6 +19,7 @@ export const useListFilterRefresh = ({ server, itemType, isClientSideSort, + itemCount, }: UseHandleListFilterChangeProps) => { const queryClient = useQueryClient(); @@ -98,11 +100,14 @@ export const useListFilterRefresh = ({ filter.sortOrder === 'DESC' ? ['desc'] : ['asc'], ); - params.successCallback(sortedResults || [], res?.totalRecordCount || 0); + params.successCallback( + sortedResults || [], + res?.totalRecordCount || itemCount, + ); return; } - params.successCallback(res?.items || [], res?.totalRecordCount || 0); + params.successCallback(res?.items || [], res?.totalRecordCount || itemCount); }, rowCount: undefined, @@ -112,7 +117,7 @@ export const useListFilterRefresh = ({ tableRef.current?.api.purgeInfiniteCache(); tableRef.current?.api.ensureIndexVisible(0, 'top'); }, - [isClientSideSort, queryClient, queryFn, queryKeyFn, server], + [isClientSideSort, itemCount, queryClient, queryFn, queryKeyFn, server], ); const handleRefreshGrid = useCallback(