From e84a4b20bc07bf61fb833cc6c7917b62e8ae9184 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Wed, 23 Apr 2025 23:27:06 -0700 Subject: [PATCH] add artist list --- src/renderer/api/controller.ts | 9 + .../api/jellyfin/jellyfin-controller.ts | 39 +- .../api/navidrome/navidrome-controller.ts | 60 +- src/renderer/api/query-keys.ts | 3 + .../api/subsonic/subsonic-controller.ts | 177 ++--- src/renderer/api/subsonic/subsonic-types.ts | 2 + src/renderer/api/types.ts | 10 +- .../virtual-table/hooks/use-virtual-table.ts | 66 +- .../components/artist-list-content.tsx | 47 ++ .../components/artist-list-grid-view.tsx | 173 +++++ .../components/artist-list-header-filters.tsx | 619 ++++++++++++++++++ .../artists/components/artist-list-header.tsx | 77 +++ .../components/artist-list-table-view.tsx | 40 ++ .../queries/artist-list-count-query.ts | 30 + .../features/artists/queries/roles-query.ts | 25 + .../artists/routes/artist-list-route.tsx | 57 ++ .../components/general/sidebar-reorder.tsx | 4 +- .../features/sidebar/components/sidebar.tsx | 4 +- src/renderer/hooks/use-list-filter-refresh.ts | 62 +- src/renderer/router/app-router.tsx | 6 + src/renderer/store/list.store.ts | 33 + src/renderer/store/settings.store.ts | 18 +- 22 files changed, 1369 insertions(+), 192 deletions(-) create mode 100644 src/renderer/features/artists/components/artist-list-content.tsx create mode 100644 src/renderer/features/artists/components/artist-list-grid-view.tsx create mode 100644 src/renderer/features/artists/components/artist-list-header-filters.tsx create mode 100644 src/renderer/features/artists/components/artist-list-header.tsx create mode 100644 src/renderer/features/artists/components/artist-list-table-view.tsx create mode 100644 src/renderer/features/artists/queries/artist-list-count-query.ts create mode 100644 src/renderer/features/artists/queries/roles-query.ts create mode 100644 src/renderer/features/artists/routes/artist-list-route.tsx diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 1dc92383..559c4405 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -99,6 +99,12 @@ export const controller: GeneralController = { getAlbumListCount(args) { return apiController('getAlbumListCount', args.apiClientProps.server?.type)?.(args); }, + getArtistList(args) { + return apiController('getArtistList', args.apiClientProps.server?.type)?.(args); + }, + getArtistListCount(args) { + return apiController('getArtistListCount', args.apiClientProps.server?.type)?.(args); + }, getDownloadUrl(args) { return apiController('getDownloadUrl', args.apiClientProps.server?.type)?.(args); }, @@ -126,6 +132,9 @@ export const controller: GeneralController = { getRandomSongList(args) { return apiController('getRandomSongList', args.apiClientProps.server?.type)?.(args); }, + getRoles(args) { + return apiController('getRoles', args.apiClientProps.server?.type)?.(args); + }, getServerInfo(args) { return apiController('getServerInfo', args.apiClientProps.server?.type)?.(args); }, diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 8621554e..1dae79ee 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -152,7 +152,6 @@ export const JellyfinController: ControllerEndpoint = { const { query, apiClientProps } = args; const res = await jfApiClient(apiClientProps).deletePlaylist({ - body: null, params: { id: query.id, }, @@ -331,6 +330,41 @@ export const JellyfinController: ControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getArtistList: async (args) => { + const { query, apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).getArtistList({ + query: { + Fields: 'Genres, DateCreated, ExternalUrls, Overview', + ImageTypeLimit: 1, + Limit: query.limit, + ParentId: query.musicFolderId, + Recursive: true, + SearchTerm: query.searchTerm, + SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name', + SortOrder: sortOrderMap.jellyfin[query.sortOrder], + StartIndex: query.startIndex, + UserId: apiClientProps.server?.userId || undefined, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get album artist list'); + } + + return { + items: res.body.Items.map((item) => + jfNormalize.albumArtist(item, apiClientProps.server), + ), + startIndex: query.startIndex, + totalRecordCount: res.body.TotalRecordCount, + }; + }, + getArtistListCount: async ({ apiClientProps, query }) => + JellyfinController.getArtistList({ + apiClientProps, + query: { ...query, limit: 1, startIndex: 0 }, + }).then((result) => result!.totalRecordCount!), getDownloadUrl: (args) => { const { apiClientProps, query } = args; @@ -559,6 +593,7 @@ export const JellyfinController: ControllerEndpoint = { totalRecordCount: res.body.Items.length || 0, }; }, + getRoles: async () => [], getServerInfo: async (args) => { const { apiClientProps } = args; @@ -775,7 +810,6 @@ export const JellyfinController: ControllerEndpoint = { const { apiClientProps, query } = args; const res = await jfApiClient(apiClientProps).movePlaylistItem({ - body: null, params: { itemId: query.trackId, newIdx: query.endingIndex.toString(), @@ -794,7 +828,6 @@ export const JellyfinController: ControllerEndpoint = { for (const chunk of chunks) { const res = await jfApiClient(apiClientProps).removeFromPlaylist({ - body: null, params: { id: query.id, }, diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 20f35594..2e3389f6 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -30,6 +30,22 @@ const VERSION_INFO: VersionInfo = [ ['0.48.0', { [ServerFeature.PLAYLISTS_SMART]: [1] }], ]; +const NAVIDROME_ROLES: Array = [ + { label: 'all artists', value: '' }, + 'arranger', + 'artist', + 'composer', + 'conductor', + 'director', + 'djmixer', + 'engineer', + 'lyricist', + 'mixer', + 'performer', + 'producer', + 'remixer', +]; + const excludeMissing = (server: ServerListItem | null) => { if (hasFeature(server, ServerFeature.BFR)) { return { missing: false }; @@ -105,7 +121,6 @@ export const NavidromeController: ControllerEndpoint = { const { query, apiClientProps } = args; const res = await ndApiClient(apiClientProps).deletePlaylist({ - body: null, params: { id: query.id, }, @@ -261,6 +276,47 @@ export const NavidromeController: ControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), + getArtistList: async (args) => { + const { query, apiClientProps } = args; + + const res = await ndApiClient(apiClientProps).getAlbumArtistList({ + query: { + _end: query.startIndex + (query.limit || 0), + _order: sortOrderMap.navidrome[query.sortOrder], + _sort: albumArtistListSortMap.navidrome[query.sortBy], + _start: query.startIndex, + name: query.searchTerm, + ...query._custom?.navidrome, + role: query.role, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get artist list'); + } + + return { + items: res.body.data.map((albumArtist) => + // Navidrome native API will return only external URL small/medium/large + // image URL. Set large image to undefined to force `albumArtist` to use + // /rest/getCoverArt.view?id=ar-... + ndNormalize.albumArtist( + { + ...albumArtist, + largeImageUrl: undefined, + }, + apiClientProps.server, + ), + ), + startIndex: query.startIndex, + totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), + }; + }, + getArtistListCount: async ({ apiClientProps, query }) => + NavidromeController.getArtistList({ + apiClientProps, + query: { ...query, limit: 1, startIndex: 0 }, + }).then((result) => result!.totalRecordCount!), getDownloadUrl: SubsonicController.getDownloadUrl, getGenreList: async (args) => { const { query, apiClientProps } = args; @@ -369,6 +425,7 @@ export const NavidromeController: ControllerEndpoint = { }; }, getRandomSongList: SubsonicController.getRandomSongList, + getRoles: async () => NAVIDROME_ROLES, getServerInfo: async (args) => { const { apiClientProps } = args; @@ -564,7 +621,6 @@ export const NavidromeController: ControllerEndpoint = { const { query, apiClientProps } = args; const res = await ndApiClient(apiClientProps).removeFromPlaylist({ - body: null, params: { id: query.id, }, diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 0dd872fe..3c1ee0cd 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -231,6 +231,9 @@ export const queryKeys: Record< return [serverId, 'playlists', 'songList'] as const; }, }, + roles: { + list: (serverId: string) => [serverId, 'roles'] as const, + }, search: { list: (serverId: string, query?: SearchQuery) => { if (query) return [serverId, 'search', 'list', query] as const; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index a2706684..83515311 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -515,6 +515,51 @@ export const SubsonicController: ControllerEndpoint = { return totalRecordCount; }, + getArtistList: async (args) => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getArtists({ + query: { + musicFolderId: query.musicFolderId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get artist list'); + } + + let artists = (res.body.artists?.index || []).flatMap((index) => index.artist); + console.log(artists.length); + if (query.role) { + artists = artists.filter( + (artist) => !artist.roles || artist.roles.includes(query.role!), + ); + } + + let results = artists.map((artist) => + ssNormalize.albumArtist(artist, apiClientProps.server, 300), + ); + + if (query.searchTerm) { + const searchResults = filter(results, (artist) => { + return artist.name.toLowerCase().includes(query.searchTerm!.toLowerCase()); + }); + + results = searchResults; + } + + if (query.sortBy) { + results = sortAlbumArtistList(results, query.sortBy, query.sortOrder); + } + + return { + items: results, + startIndex: query.startIndex, + totalRecordCount: results?.length || 0, + }; + }, + getArtistListCount: async (args) => + SubsonicController.getArtistList(args).then((res) => res!.totalRecordCount!), getDownloadUrl: (args) => { const { apiClientProps, query } = args; @@ -711,6 +756,31 @@ export const SubsonicController: ControllerEndpoint = { totalRecordCount: res.body.randomSongs?.song?.length || 0, }; }, + getRoles: async (args) => { + const { apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getArtists({}); + + if (res.status !== 200) { + throw new Error('Failed to get artist list'); + } + + const roles = new Set(); + + for (const index of res.body.artists?.index || []) { + for (const artist of index.artist) { + for (const role of artist.roles || []) { + roles.add(role); + } + } + } + + const final: Array = Array.from(roles).sort(); + if (final.length > 0) { + final.splice(0, 0, { label: 'all artists', value: '' }); + } + return final; + }, getServerInfo: async (args) => { const { apiClientProps } = args; @@ -1230,6 +1300,7 @@ export const SubsonicController: ControllerEndpoint = { return null; }, + search: async (args) => { const { query, apiClientProps } = args; @@ -1296,109 +1367,3 @@ export const SubsonicController: ControllerEndpoint = { return null; }, }; - -// export const getAlbumArtistDetail = async ( -// args: AlbumArtistDetailArgs, -// ): Promise => { -// const { server, signal, query } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams: SSAlbumArtistDetailParams = { -// id: query.id, -// ...defaultParams, -// }; - -// const data = await api -// .get('/getArtist.view', { -// prefixUrl: server?.url, -// searchParams, -// signal, -// }) -// .json(); - -// return data.artist; -// }; - -// const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise => { -// const { signal, server, query } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams: SSAlbumArtistListParams = { -// musicFolderId: query.musicFolderId, -// ...defaultParams, -// }; - -// const data = await api -// .get('rest/getArtists.view', { -// prefixUrl: server?.url, -// searchParams, -// signal, -// }) -// .json(); - -// const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist); - -// return { -// items: artists, -// startIndex: query.startIndex, -// totalRecordCount: null, -// }; -// }; - -// const getGenreList = async (args: GenreListArgs): Promise => { -// const { server, signal } = args; -// const defaultParams = getDefaultParams(server); - -// const data = await api -// .get('rest/getGenres.view', { -// prefixUrl: server?.url, -// searchParams: defaultParams, -// signal, -// }) -// .json(); - -// return data.genres.genre; -// }; - -// const getAlbumDetail = async (args: AlbumDetailArgs): Promise => { -// const { server, query, signal } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams = { -// id: query.id, -// ...defaultParams, -// }; - -// const data = await api -// .get('rest/getAlbum.view', { -// prefixUrl: server?.url, -// searchParams: parseSearchParams(searchParams), -// signal, -// }) -// .json(); - -// const { song: songs, ...dataWithoutSong } = data.album; -// return { ...dataWithoutSong, songs }; -// }; - -// const getAlbumList = async (args: AlbumListArgs): Promise => { -// const { server, query, signal } = args; -// const defaultParams = getDefaultParams(server); - -// const searchParams = { -// ...defaultParams, -// }; -// const data = await api -// .get('rest/getAlbumList2.view', { -// prefixUrl: server?.url, -// searchParams: parseSearchParams(searchParams), -// signal, -// }) -// .json(); - -// return { -// items: data.albumList2.album, -// startIndex: query.startIndex, -// totalRecordCount: null, -// }; -// }; diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index b1d84877..8ce91cc7 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -162,6 +162,7 @@ const albumArtist = z.object({ coverArt: z.string().optional(), id, name: z.string(), + roles: z.array(z.string()).optional(), starred: z.string().optional(), }); @@ -175,6 +176,7 @@ const artistListEntry = albumArtist.pick({ coverArt: true, id: true, name: true, + roles: true, starred: true, }); diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index f6df5ce0..f30cfaa9 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -672,7 +672,7 @@ export type AlbumArtistDetailQuery = { id: string }; export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs; // Artist List -export type ArtistListResponse = BasePaginatedResponse | null | undefined; +export type ArtistListResponse = BasePaginatedResponse | null | undefined; export enum ArtistListSort { ALBUM = 'album', @@ -695,6 +695,8 @@ export interface ArtistListQuery extends BaseQuery { }; limit?: number; musicFolderId?: string; + role?: string; + searchTerm?: string; startIndex: number; } @@ -1245,7 +1247,8 @@ export type ControllerEndpoint = { getAlbumList: (args: AlbumListArgs) => Promise; getAlbumListCount: (args: AlbumListArgs) => Promise; // getArtistInfo?: (args: any) => void; - // getArtistList?: (args: ArtistListArgs) => Promise; + getArtistList: (args: ArtistListArgs) => Promise; + getArtistListCount: (args: ArtistListArgs) => Promise; getDownloadUrl: (args: DownloadArgs) => string; getGenreList: (args: GenreListArgs) => Promise; getLyrics?: (args: LyricsArgs) => Promise; @@ -1255,6 +1258,7 @@ export type ControllerEndpoint = { getPlaylistListCount: (args: PlaylistListArgs) => Promise; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; getRandomSongList: (args: RandomSongListArgs) => Promise; + getRoles: (args: BaseEndpointArgs) => Promise>; getServerInfo: (args: ServerInfoArgs) => Promise; getSimilarSongs: (args: SimilarSongsArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; @@ -1417,7 +1421,7 @@ export const sortSongList = (songs: QueueSong[], sortBy: SongListSort, sortOrder export const sortAlbumArtistList = ( artists: AlbumArtist[], - sortBy: AlbumArtistListSort, + sortBy: AlbumArtistListSort | ArtistListSort, sortOrder: SortOrder, ) => { const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; 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 f0e05bfa..1009b150 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -105,44 +105,42 @@ export const useVirtualTable = >({ const queryKeyFn: | ((serverId: string, query: Record, pagination: QueryPagination) => QueryKey) | null = useMemo(() => { - if (itemType === LibraryItem.ALBUM) { - return queryKeys.albums.list; + switch (itemType) { + case LibraryItem.ALBUM: + return queryKeys.albums.list; + case LibraryItem.ALBUM_ARTIST: + return queryKeys.albumArtists.list; + case LibraryItem.ARTIST: + return queryKeys.artists.list; + case LibraryItem.GENRE: + return queryKeys.genres.list; + case LibraryItem.PLAYLIST: + return queryKeys.playlists.list; + case LibraryItem.SONG: + return queryKeys.songs.list; + default: + return null; } - if (itemType === LibraryItem.ALBUM_ARTIST) { - return queryKeys.albumArtists.list; - } - if (itemType === LibraryItem.PLAYLIST) { - return queryKeys.playlists.list; - } - if (itemType === LibraryItem.SONG) { - return queryKeys.songs.list; - } - if (itemType === LibraryItem.GENRE) { - return queryKeys.genres.list; - } - - return null; }, [itemType]); const queryFn: ((args: any) => Promise | null | undefined>) | null = useMemo(() => { - if (itemType === LibraryItem.ALBUM) { - return api.controller.getAlbumList; + switch (itemType) { + case LibraryItem.ALBUM: + return api.controller.getAlbumList; + case LibraryItem.ALBUM_ARTIST: + return api.controller.getAlbumArtistList; + case LibraryItem.ARTIST: + return api.controller.getArtistList; + case LibraryItem.GENRE: + return api.controller.getGenreList; + case LibraryItem.PLAYLIST: + return api.controller.getPlaylistList; + case LibraryItem.SONG: + return api.controller.getSongList; + default: + return null; } - if (itemType === LibraryItem.ALBUM_ARTIST) { - return api.controller.getAlbumArtistList; - } - if (itemType === LibraryItem.PLAYLIST) { - return api.controller.getPlaylistList; - } - if (itemType === LibraryItem.SONG) { - return api.controller.getSongList; - } - if (itemType === LibraryItem.GENRE) { - return api.controller.getGenreList; - } - - return null; }, [itemType]); const onGridReady = useCallback( @@ -390,7 +388,9 @@ export const useVirtualTable = >({ break; case LibraryItem.ARTIST: navigate( - generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, { artistId: e.data.id }), + generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, { + albumArtistId: e.data.id, + }), ); break; case LibraryItem.PLAYLIST: diff --git a/src/renderer/features/artists/components/artist-list-content.tsx b/src/renderer/features/artists/components/artist-list-content.tsx new file mode 100644 index 00000000..436184b4 --- /dev/null +++ b/src/renderer/features/artists/components/artist-list-content.tsx @@ -0,0 +1,47 @@ +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { lazy, MutableRefObject, Suspense } from 'react'; +import { Spinner } from '/@/renderer/components'; +import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import { ListDisplayType } from '/@/renderer/types'; +import { useListStoreByKey } from '../../../store/list.store'; +import { useListContext } from '/@/renderer/context/list-context'; + +const ArtistListGridView = lazy(() => + import('/@/renderer/features/artists/components/artist-list-grid-view').then((module) => ({ + default: module.ArtistListGridView, + })), +); + +const ArtistListTableView = lazy(() => + import('/@/renderer/features/artists/components/artist-list-table-view').then((module) => ({ + default: module.ArtistListTableView, + })), +); + +interface ArtistListContentProps { + gridRef: MutableRefObject; + itemCount?: number; + tableRef: MutableRefObject; +} + +export const ArtistListContent = ({ itemCount, gridRef, tableRef }: ArtistListContentProps) => { + const { pageKey } = useListContext(); + const { display } = useListStoreByKey({ key: pageKey }); + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + + return ( + }> + {isGrid ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/renderer/features/artists/components/artist-list-grid-view.tsx b/src/renderer/features/artists/components/artist-list-grid-view.tsx new file mode 100644 index 00000000..ba8d6ddd --- /dev/null +++ b/src/renderer/features/artists/components/artist-list-grid-view.tsx @@ -0,0 +1,173 @@ +import { QueryKey, useQueryClient } from '@tanstack/react-query'; +import { MutableRefObject, useCallback, useMemo } from 'react'; +import AutoSizer, { Size } from 'react-virtualized-auto-sizer'; +import { ListOnScrollProps } from 'react-window'; +import { VirtualGridAutoSizerContainer } from '../../../components/virtual-grid/virtual-grid-wrapper'; +import { useListStoreByKey } from '../../../store/list.store'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { + AlbumArtist, + ArtistListQuery, + ArtistListResponse, + ArtistListSort, + LibraryItem, +} from '/@/renderer/api/types'; +import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components'; +import { VirtualInfiniteGrid, VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import { useListContext } from '/@/renderer/context/list-context'; +import { usePlayQueueAdd } from '/@/renderer/features/player'; +import { AppRoute } from '/@/renderer/router/routes'; +import { useCurrentServer, useListStoreActions } from '/@/renderer/store'; +import { CardRow, ListDisplayType } from '/@/renderer/types'; +import { useHandleFavorite } from '/@/renderer/features/shared/hooks/use-handle-favorite'; + +interface ArtistListGridViewProps { + gridRef: MutableRefObject; + itemCount?: number; +} + +export const ArtistListGridView = ({ itemCount, gridRef }: ArtistListGridViewProps) => { + const queryClient = useQueryClient(); + const server = useCurrentServer(); + const handlePlayQueueAdd = usePlayQueueAdd(); + + const { pageKey } = useListContext(); + const { grid, display, filter } = useListStoreByKey({ key: pageKey }); + const { setGrid } = useListStoreActions(); + const handleFavorite = useHandleFavorite({ gridRef, server }); + + const fetchInitialData = useCallback(() => { + const query: Omit = { + ...filter, + }; + + const queriesFromCache: [QueryKey, ArtistListResponse][] = queryClient.getQueriesData({ + exact: false, + fetchStatus: 'idle', + queryKey: queryKeys.artists.list(server?.id || '', query), + stale: false, + }); + + const itemData = []; + + for (const [, data] of queriesFromCache) { + const { items, startIndex } = data || {}; + + if (items && items.length !== 1 && startIndex !== undefined) { + let itemIndex = 0; + for ( + let rowIndex = startIndex; + rowIndex < startIndex + items.length; + rowIndex += 1 + ) { + itemData[rowIndex] = items[itemIndex]; + itemIndex += 1; + } + } + } + + return itemData; + }, [filter, queryClient, server?.id]); + + const fetch = useCallback( + async ({ skip: startIndex, take: limit }: { skip: number; take: number }) => { + const query: ArtistListQuery = { + ...filter, + limit, + startIndex, + }; + + const queryKey = queryKeys.artists.list(server?.id || '', query); + + const artistsRes = await queryClient.fetchQuery( + queryKey, + async ({ signal }) => + api.controller.getArtistList({ + apiClientProps: { + server, + signal, + }, + query, + }), + { cacheTime: 1000 * 60 * 1 }, + ); + + return artistsRes; + }, + [filter, queryClient, server], + ); + + const handleGridScroll = useCallback( + (e: ListOnScrollProps) => { + setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey }); + }, + [pageKey, setGrid], + ); + + const cardRows = useMemo(() => { + const rows: CardRow[] = [ALBUMARTIST_CARD_ROWS.name]; + + switch (filter.sortBy) { + case ArtistListSort.DURATION: + rows.push(ALBUMARTIST_CARD_ROWS.duration); + break; + case ArtistListSort.FAVORITED: + break; + case ArtistListSort.NAME: + break; + case ArtistListSort.ALBUM_COUNT: + rows.push(ALBUMARTIST_CARD_ROWS.albumCount); + break; + case ArtistListSort.PLAY_COUNT: + rows.push(ALBUMARTIST_CARD_ROWS.playCount); + break; + case ArtistListSort.RANDOM: + break; + case ArtistListSort.RATING: + rows.push(ALBUMARTIST_CARD_ROWS.rating); + break; + case ArtistListSort.RECENTLY_ADDED: + break; + case ArtistListSort.SONG_COUNT: + rows.push(ALBUMARTIST_CARD_ROWS.songCount); + break; + case ArtistListSort.RELEASE_DATE: + break; + } + + return rows; + }, [filter.sortBy]); + + return ( + + + {({ height, width }: Size) => ( + + )} + + + ); +}; diff --git a/src/renderer/features/artists/components/artist-list-header-filters.tsx b/src/renderer/features/artists/components/artist-list-header-filters.tsx new file mode 100644 index 00000000..a6ac45cf --- /dev/null +++ b/src/renderer/features/artists/components/artist-list-header-filters.tsx @@ -0,0 +1,619 @@ +import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react'; +import { IDatasource } from '@ag-grid-community/core'; +import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; +import { Divider, Flex, Group, Stack } from '@mantine/core'; +import { useQueryClient } from '@tanstack/react-query'; +import debounce from 'lodash/debounce'; +import { useTranslation } from 'react-i18next'; +import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri'; +import { useListContext } from '../../../context/list-context'; +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { + ArtistListQuery, + ArtistListSort, + LibraryItem, + ServerType, + SortOrder, +} from '/@/renderer/api/types'; +import { + Button, + DropdownMenu, + MultiSelect, + Select, + Slider, + Switch, + Text, +} from '/@/renderer/components'; +import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; +import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table'; +import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared'; +import { useContainerQuery } from '/@/renderer/hooks'; +import { + ArtistListFilter, + useCurrentServer, + useListStoreActions, + useListStoreByKey, +} from '/@/renderer/store'; +import { ListDisplayType, TableColumn } from '/@/renderer/types'; +import i18n from '/@/i18n/i18n'; +import { useRoles } from '/@/renderer/features/artists/queries/roles-query'; + +const FILTERS = { + jellyfin: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.album', { postProcess: 'titleCase' }), + value: ArtistListSort.ALBUM, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.duration', { postProcess: 'titleCase' }), + value: ArtistListSort.DURATION, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: ArtistListSort.NAME, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.random', { postProcess: 'titleCase' }), + value: ArtistListSort.RANDOM, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.recentlyAdded', { postProcess: 'titleCase' }), + value: ArtistListSort.RECENTLY_ADDED, + }, + ], + navidrome: [ + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }), + value: ArtistListSort.ALBUM_COUNT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }), + value: ArtistListSort.FAVORITED, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.mostPlayed', { postProcess: 'titleCase' }), + value: ArtistListSort.PLAY_COUNT, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: ArtistListSort.NAME, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.rating', { postProcess: 'titleCase' }), + value: ArtistListSort.RATING, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.songCount', { postProcess: 'titleCase' }), + value: ArtistListSort.SONG_COUNT, + }, + ], + subsonic: [ + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.albumCount', { postProcess: 'titleCase' }), + value: ArtistListSort.ALBUM_COUNT, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.isFavorited', { postProcess: 'titleCase' }), + value: ArtistListSort.FAVORITED, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: ArtistListSort.NAME, + }, + { + defaultOrder: SortOrder.DESC, + name: i18n.t('filter.rating', { postProcess: 'titleCase' }), + value: ArtistListSort.RATING, + }, + ], +}; + +interface ArtistListHeaderFiltersProps { + gridRef: MutableRefObject; + tableRef: MutableRefObject; +} + +export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderFiltersProps) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const server = useCurrentServer(); + const { pageKey } = useListContext(); + const { display, table, grid, filter } = useListStoreByKey({ + key: pageKey, + }); + const { setFilter, setTable, setTablePagination, setDisplayType, setGrid } = + useListStoreActions(); + const cq = useContainerQuery(); + const roles = useRoles({ + options: { + cacheTime: 1000 * 60 * 60 * 2, + staleTime: 1000 * 60 * 60 * 2, + }, + query: {}, + serverId: server?.id, + }); + + const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER; + const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id }); + + const sortByLabel = + (server?.type && + FILTERS[server.type as keyof typeof FILTERS].find((f) => f.value === filter.sortBy) + ?.name) || + t('common.unknown', { postProcess: 'titleCase' }); + + const handleItemSize = (e: number) => { + if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) { + setTable({ data: { rowHeight: e }, key: pageKey }); + } else { + setGrid({ data: { itemSize: e }, key: pageKey }); + } + }; + + const handleItemGap = (e: number) => { + setGrid({ data: { itemGap: e }, key: pageKey }); + }; + + const debouncedHandleItemSize = debounce(handleItemSize, 20); + + const fetch = useCallback( + async (startIndex: number, limit: number, filters: ArtistListFilter) => { + const queryKey = queryKeys.artists.list(server?.id || '', { + limit, + startIndex, + ...filters, + }); + + const albums = await queryClient.fetchQuery( + queryKey, + async ({ signal }) => + api.controller.getArtistList({ + apiClientProps: { + server, + signal, + }, + query: { + limit, + startIndex, + ...filters, + }, + }), + { cacheTime: 1000 * 60 * 1 }, + ); + + return albums; + }, + [queryClient, server], + ); + + const handleFilterChange = useCallback( + async (filters: ArtistListFilter) => { + if (display === ListDisplayType.TABLE || display === ListDisplayType.TABLE_PAGINATED) { + const dataSource: IDatasource = { + getRows: async (params) => { + const limit = params.endRow - params.startRow; + const startIndex = params.startRow; + + const queryKey = queryKeys.artists.list(server?.id || '', { + limit, + startIndex, + ...filters, + }); + + const artistsRes = await queryClient.fetchQuery( + queryKey, + async ({ signal }) => + api.controller.getArtistList({ + apiClientProps: { + server, + signal, + }, + query: { + limit, + startIndex, + ...filters, + }, + }), + { cacheTime: 1000 * 60 * 1 }, + ); + + params.successCallback( + artistsRes?.items || [], + artistsRes?.totalRecordCount || 0, + ); + }, + rowCount: undefined, + }; + tableRef.current?.api.setDatasource(dataSource); + tableRef.current?.api.purgeInfiniteCache(); + tableRef.current?.api.ensureIndexVisible(0, 'top'); + + if (display === ListDisplayType.TABLE_PAGINATED) { + setTablePagination({ data: { currentPage: 0 }, key: pageKey }); + } + } else { + gridRef.current?.scrollTo(0); + gridRef.current?.resetLoadMoreItemsCache(); + + // Refetching within the virtualized grid may be inconsistent due to it refetching + // using an outdated set of filters. To avoid this, we fetch using the updated filters + // and then set the grid's data here. + const data = await fetch(0, 200, filters); + + if (!data?.items) return; + gridRef.current?.setItemData(data.items); + } + }, + [display, tableRef, server, queryClient, setTablePagination, pageKey, gridRef, fetch], + ); + + const handleSetSortBy = useCallback( + (e: MouseEvent) => { + if (!e.currentTarget?.value || !server?.type) return; + + const sortOrder = FILTERS[server.type as keyof typeof FILTERS].find( + (f) => f.value === e.currentTarget.value, + )?.defaultOrder; + + const updatedFilters = setFilter({ + data: { + sortBy: e.currentTarget.value as ArtistListSort, + sortOrder: sortOrder || SortOrder.ASC, + }, + itemType: LibraryItem.ARTIST, + key: pageKey, + }) as ArtistListFilter; + + handleFilterChange(updatedFilters); + }, + [handleFilterChange, pageKey, server?.type, setFilter], + ); + + const handleSetMusicFolder = useCallback( + (e: MouseEvent) => { + if (!e.currentTarget?.value) return; + + let updatedFilters = null; + if (e.currentTarget.value === String(filter.musicFolderId)) { + updatedFilters = setFilter({ + data: { musicFolderId: undefined }, + itemType: LibraryItem.ARTIST, + key: pageKey, + }) as ArtistListFilter; + } else { + updatedFilters = setFilter({ + data: { musicFolderId: e.currentTarget.value }, + itemType: LibraryItem.ARTIST, + key: pageKey, + }) as ArtistListFilter; + } + + handleFilterChange(updatedFilters); + }, + [filter.musicFolderId, handleFilterChange, setFilter, pageKey], + ); + + const handleToggleSortOrder = useCallback(() => { + const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC; + const updatedFilters = setFilter({ + data: { sortOrder: newSortOrder }, + itemType: LibraryItem.ARTIST, + key: pageKey, + }) as ArtistListFilter; + handleFilterChange(updatedFilters); + }, [filter.sortOrder, handleFilterChange, pageKey, setFilter]); + + const handleSetViewType = useCallback( + (e: MouseEvent) => { + if (!e.currentTarget?.value) return; + + setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey }); + }, + [pageKey, setDisplayType], + ); + + const handleTableColumns = (values: TableColumn[]) => { + const existingColumns = table.columns; + + if (values.length === 0) { + return setTable({ + data: { + columns: [], + }, + key: pageKey, + }); + } + + // If adding a column + if (values.length > existingColumns.length) { + const newColumn = { column: values[values.length - 1], width: 100 }; + + setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey }); + } else { + // If removing a column + const removed = existingColumns.filter((column) => !values.includes(column.column)); + const newColumns = existingColumns.filter((column) => !removed.includes(column)); + + setTable({ data: { columns: newColumns }, key: pageKey }); + } + + return tableRef.current?.api.sizeColumnsToFit(); + }; + + const handleAutoFitColumns = (e: ChangeEvent) => { + setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey }); + + if (e.currentTarget.checked) { + tableRef.current?.api.sizeColumnsToFit(); + } + }; + + const handleRefresh = useCallback(() => { + queryClient.invalidateQueries(queryKeys.artists.list(server?.id || '')); + handleFilterChange(filter); + }, [filter, handleFilterChange, queryClient, server?.id]); + + const handleSetRole = useCallback( + (e: string | null) => { + const updatedFilters = setFilter({ + data: { + role: e || '', + }, + itemType: LibraryItem.ARTIST, + key: pageKey, + }) as ArtistListFilter; + handleFilterChange(updatedFilters); + }, + [handleFilterChange, pageKey, setFilter], + ); + + return ( + + + + + + + + {FILTERS[server?.type as keyof typeof FILTERS].map((f) => ( + + {f.name} + + ))} + + + + + {server?.type === ServerType.JELLYFIN && ( + <> + + + + + + + {musicFoldersQuery.data?.items.map((folder) => ( + + {folder.name} + + ))} + + + + )} + {roles.data?.length && ( + <> + +