diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index 5d3b5cb3..ade67fb0 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -140,7 +140,9 @@ const normalizeSong = ( imageUrl: null, name: entry.Name, })), - bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)), + bitRate: + item.MediaSources?.[0].Bitrate && + Number(Math.trunc(item.MediaSources[0].Bitrate / 1000)), bpm: null, channels: null, comment: null, @@ -149,7 +151,12 @@ const normalizeSong = ( createdAt: item.DateCreated, discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1, duration: item.RunTimeTicks / 10000000, - genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })), + genres: item.GenreItems.map((entry) => ({ + id: entry.Id, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: entry.Name, + })), id: item.Id, imagePlaceholderUrl: null, imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }), @@ -202,7 +209,12 @@ const normalizeAlbum = ( backdropImageUrl: null, createdAt: item.DateCreated, duration: item.RunTimeTicks / 10000000, - genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), + genres: item.GenreItems.map((entry) => ({ + id: entry.Id, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: entry.Name, + })), id: item.Id, imagePlaceholderUrl: null, imageUrl: getAlbumCoverArtUrl({ @@ -254,7 +266,12 @@ const normalizeAlbumArtist = ( backgroundImageUrl: null, biography: item.Overview || null, duration: item.RunTimeTicks / 10000, - genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), + genres: item.GenreItems.map((entry) => ({ + id: entry.Id, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: entry.Name, + })), id: item.Id, imageUrl: getAlbumArtistCoverArtUrl({ baseUrl: server?.url || '', @@ -290,7 +307,12 @@ const normalizePlaylist = ( return { description: item.Overview || null, duration: item.RunTimeTicks / 10000, - genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })), + genres: item.GenreItems.map((entry) => ({ + id: entry.Id, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: entry.Name, + })), id: item.Id, imagePlaceholderUrl, imageUrl: imageUrl || null, @@ -339,6 +361,8 @@ const normalizeGenre = (item: JFGenre): Genre => { return { albumCount: undefined, id: item.Id, + imageUrl: null, + itemType: LibraryItem.GENRE, name: item.Name, songCount: undefined, }; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index da120dcd..50c78f6c 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -112,7 +112,7 @@ const getGenreList = async (args: GenreListArgs): Promise => } return { - items: res.body.data, + items: res.body.data.map((genre) => ndNormalize.genre(genre)), startIndex: query.startIndex || 0, totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index 3c5ef2e1..22ca37cf 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -1,9 +1,18 @@ import { nanoid } from 'nanoid'; -import { Song, LibraryItem, Album, Playlist, User, AlbumArtist } from '/@/renderer/api/types'; +import { + Song, + LibraryItem, + Album, + Playlist, + User, + AlbumArtist, + Genre, +} from '/@/renderer/api/types'; 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'; const getImageUrl = (args: { url: string | null }) => { const { url } = args; @@ -81,7 +90,12 @@ const normalizeSong = ( createdAt: item.createdAt.split('T')[0], discNumber: item.discNumber, duration: item.duration, - genres: item.genres, + genres: item.genres?.map((genre) => ({ + id: genre.id, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: genre.name, + })), id, imagePlaceholderUrl, imageUrl, @@ -130,7 +144,12 @@ const normalizeAlbum = ( backdropImageUrl: imageBackdropUrl, createdAt: item.createdAt.split('T')[0], duration: item.duration * 1000 || null, - genres: item.genres, + genres: item.genres?.map((genre) => ({ + id: genre.id, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: genre.name, + })), id: item.id, imagePlaceholderUrl, imageUrl, @@ -166,7 +185,12 @@ const normalizeAlbumArtist = ( backgroundImageUrl: null, biography: item.biography || null, duration: null, - genres: item.genres, + genres: item.genres?.map((genre) => ({ + id: genre.id, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: genre.name, + })), id: item.id, imageUrl: imageUrl || null, itemType: LibraryItem.ALBUM_ARTIST, @@ -222,6 +246,17 @@ const normalizePlaylist = ( }; }; +const normalizeGenre = (item: NDGenre): Genre => { + return { + albumCount: undefined, + id: item.id, + imageUrl: null, + itemType: LibraryItem.GENRE, + name: item.name, + songCount: undefined, + }; +}; + const normalizeUser = (item: z.infer): User => { return { createdAt: item.createdAt, @@ -237,6 +272,7 @@ const normalizeUser = (item: z.infer): User => { export const ndNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, + genre: normalizeGenre, playlist: normalizePlaylist, song: normalizeSong, user: normalizeUser, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index a3640ba2..303c00e6 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -137,6 +137,8 @@ export type AuthenticationResponse = { export type Genre = { albumCount?: number; id: string; + imageUrl: string | null; + itemType: LibraryItem.GENRE; name: string; songCount?: number; }; diff --git a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts index f9bb806f..a4791fc0 100644 --- a/src/renderer/components/virtual-table/hooks/use-virtual-table.ts +++ b/src/renderer/components/virtual-table/hooks/use-virtual-table.ts @@ -166,6 +166,8 @@ export const useVirtualTable = ({ }, }); + console.log('res', res); + return res; })) as BasePaginatedResponse; diff --git a/src/renderer/features/context-menu/context-menu-items.tsx b/src/renderer/features/context-menu/context-menu-items.tsx index b4927b39..e7ee6c48 100644 --- a/src/renderer/features/context-menu/context-menu-items.tsx +++ b/src/renderer/features/context-menu/context-menu-items.tsx @@ -52,6 +52,13 @@ export const ALBUM_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { children: true, disabled: false, id: 'setRating' }, ]; +export const GENRE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ + { id: 'play' }, + { id: 'playLast' }, + { divider: true, id: 'playNext' }, + { divider: true, id: 'addToPlaylist' }, +]; + export const ARTIST_CONTEXT_MENU_ITEMS: SetContextMenuItems = [ { id: 'play' }, { id: 'playLast' }, diff --git a/src/renderer/features/context-menu/context-menu-provider.tsx b/src/renderer/features/context-menu/context-menu-provider.tsx index 93b4b5f4..5d6b913c 100644 --- a/src/renderer/features/context-menu/context-menu-provider.tsx +++ b/src/renderer/features/context-menu/context-menu-provider.tsx @@ -196,6 +196,12 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { playType, }); break; + case LibraryItem.GENRE: + handlePlayQueueAdd?.({ + byItemType: { id: ctx.data.map((item) => item.id), type: ctx.type }, + playType, + }); + break; case LibraryItem.SONG: handlePlayQueueAdd?.({ byData: ctx.data, playType }); break; @@ -403,9 +409,11 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { const albumId: string[] = []; const artistId: string[] = []; const songId: string[] = []; + const genreId: string[] = []; if (ctx.dataNodes) { for (const node of ctx.dataNodes) { + console.log('node.data.itemType :>> ', node.data.itemType); switch (node.data.itemType) { case LibraryItem.ALBUM: albumId.push(node.data.id); @@ -413,6 +421,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { case LibraryItem.ARTIST: artistId.push(node.data.id); break; + case LibraryItem.GENRE: + genreId.push(node.data.id); + break; case LibraryItem.SONG: songId.push(node.data.id); break; @@ -427,6 +438,9 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { case LibraryItem.ARTIST: artistId.push(item.id); break; + case LibraryItem.GENRE: + genreId.push(item.id); + break; case LibraryItem.SONG: songId.push(item.id); break; @@ -434,10 +448,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => { } } + console.log('genreId', genreId); + openContextModal({ innerProps: { albumId: albumId.length > 0 ? albumId : undefined, artistId: artistId.length > 0 ? artistId : undefined, + genreId: genreId.length > 0 ? genreId : undefined, songId: songId.length > 0 ? songId : undefined, }, modal: 'addToPlaylist', diff --git a/src/renderer/features/genres/components/genre-list-table-view.tsx b/src/renderer/features/genres/components/genre-list-table-view.tsx index 925565d1..665e9816 100644 --- a/src/renderer/features/genres/components/genre-list-table-view.tsx +++ b/src/renderer/features/genres/components/genre-list-table-view.tsx @@ -4,7 +4,7 @@ import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-gr import { VirtualTable } from '/@/renderer/components/virtual-table'; import { useVirtualTable } from '/@/renderer/components/virtual-table/hooks/use-virtual-table'; import { useListContext } from '/@/renderer/context/list-context'; -import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; +import { GENRE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; import { useCurrentServer } from '/@/renderer/store'; import { MutableRefObject, useCallback } from 'react'; import { RowDoubleClickedEvent } from '@ag-grid-community/core'; @@ -22,7 +22,7 @@ export const GenreListTableView = ({ tableRef, itemCount }: GenreListTableViewPr const navigate = useNavigate(); const tableProps = useVirtualTable({ - contextMenu: ALBUM_CONTEXT_MENU_ITEMS, + contextMenu: GENRE_CONTEXT_MENU_ITEMS, customFilters, itemCount, itemType: LibraryItem.GENRE, diff --git a/src/renderer/features/genres/routes/genre-list-route.tsx b/src/renderer/features/genres/routes/genre-list-route.tsx index 2395a1c6..13389426 100644 --- a/src/renderer/features/genres/routes/genre-list-route.tsx +++ b/src/renderer/features/genres/routes/genre-list-route.tsx @@ -1,24 +1,18 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact'; -import { useCallback, useMemo, useRef } from 'react'; -import { api } from '/@/renderer/api'; -import { queryKeys } from '/@/renderer/api/query-keys'; -import { GenreListSort, LibraryItem, SortOrder } from '/@/renderer/api/types'; +import { useMemo, useRef } from 'react'; +import { GenreListSort, SortOrder } from '/@/renderer/api/types'; import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid'; import { ListContext } from '/@/renderer/context/list-context'; import { GenreListContent } from '/@/renderer/features/genres/components/genre-list-content'; import { GenreListHeader } from '/@/renderer/features/genres/components/genre-list-header'; import { useGenreList } from '/@/renderer/features/genres/queries/genre-list-query'; -import { usePlayQueueAdd } from '/@/renderer/features/player'; import { AnimatedPage } from '/@/renderer/features/shared'; -import { queryClient } from '/@/renderer/lib/react-query'; import { useCurrentServer } from '/@/renderer/store'; -import { Play } from '/@/renderer/types'; const GenreListRoute = () => { const gridRef = useRef(null); const tableRef = useRef(null); const server = useCurrentServer(); - const handlePlayQueueAdd = usePlayQueueAdd(); const pageKey = 'genre'; const itemCountCheck = useGenreList({ @@ -36,44 +30,11 @@ const GenreListRoute = () => { ? undefined : itemCountCheck.data?.totalRecordCount; - const handlePlay = useCallback( - async (args: { initialSongId?: string; playType: Play }) => { - if (!itemCount || itemCount === 0) return; - const { playType } = args; - const query = { - startIndex: 0, - }; - const queryKey = queryKeys.albums.list(server?.id || '', query); - - const albumListRes = await queryClient.fetchQuery({ - queryFn: ({ signal }) => { - return api.controller.getAlbumList({ - apiClientProps: { server, signal }, - query, - }); - }, - queryKey, - }); - - const albumIds = albumListRes?.items?.map((a) => a.id) || []; - - handlePlayQueueAdd?.({ - byItemType: { - id: albumIds, - type: LibraryItem.ALBUM, - }, - playType, - }); - }, - [handlePlayQueueAdd, itemCount, server], - ); - const providerValue = useMemo(() => { return { - handlePlay, pageKey, }; - }, [handlePlay]); + }, []); return ( diff --git a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts index 9423f282..c086ef3e 100644 --- a/src/renderer/features/player/hooks/use-handle-playqueue-add.ts +++ b/src/renderer/features/player/hooks/use-handle-playqueue-add.ts @@ -25,6 +25,7 @@ import { getAlbumSongsById, getAlbumArtistSongsById, getSongsByQuery, + getGenreSongsById, } from '/@/renderer/features/player/utils'; import { queryKeys } from '/@/renderer/api/query-keys'; @@ -38,6 +39,9 @@ const getRootQueryKey = (itemType: LibraryItem, serverId: string) => { case LibraryItem.ALBUM_ARTIST: queryKey = queryKeys.songs.list(serverId); break; + case LibraryItem.GENRE: + queryKey = queryKeys.songs.list(serverId); + break; case LibraryItem.PLAYLIST: queryKey = queryKeys.playlists.songList(serverId); break; @@ -112,6 +116,8 @@ export const useHandlePlayQueueAdd = () => { queryClient, server, }); + } else if (itemType === LibraryItem.GENRE) { + songList = await getGenreSongsById({ id, query, queryClient, server }); } else if (itemType === LibraryItem.SONG) { if (id?.length === 1) { songList = await getSongById({ id: id?.[0], queryClient, server }); diff --git a/src/renderer/features/player/utils.ts b/src/renderer/features/player/utils.ts index 3be679fc..0b16f2aa 100644 --- a/src/renderer/features/player/utils.ts +++ b/src/renderer/features/player/utils.ts @@ -9,7 +9,7 @@ import { SongListSort, SortOrder, } from '/@/renderer/api/types'; -import { ServerListItem } from '/@/renderer/types'; +import { ServerListItem, ServerType } from '/@/renderer/types'; export const getPlaylistSongsById = async (args: { id: string; @@ -86,6 +86,65 @@ export const getAlbumSongsById = async (args: { return res; }; +export const getGenreSongsById = async (args: { + id: string[]; + orderByIds?: boolean; + query?: Partial; + queryClient: QueryClient; + server: ServerListItem | null; +}) => { + const { id, queryClient, server, query } = args; + + const data: SongListResponse = { + items: [], + startIndex: 0, + totalRecordCount: 0, + }; + for (const genreId of id) { + const queryFilter: SongListQuery = { + _custom: { + ...(server?.type === ServerType.JELLYFIN && { + jellyfin: { + GenreIds: genreId, + }, + }), + ...(server?.type === ServerType.NAVIDROME && { + navidrome: { + genre_id: genreId, + }, + }), + }, + sortBy: SongListSort.GENRE, + sortOrder: SortOrder.ASC, + startIndex: 0, + ...query, + }; + + const queryKey = queryKeys.songs.list(server?.id, queryFilter); + + const res = await queryClient.fetchQuery( + queryKey, + async ({ signal }) => + api.controller.getSongList({ + apiClientProps: { + server, + signal, + }, + query: queryFilter, + }), + { + cacheTime: 1000 * 60, + staleTime: 1000 * 60, + }, + ); + + data.items.push(...res!.items); + data.totalRecordCount += res!.totalRecordCount; + } + + return data; +}; + export const getAlbumArtistSongsById = async (args: { id: string[]; orderByIds?: boolean; diff --git a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx index f3fa383c..6e552b87 100644 --- a/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx +++ b/src/renderer/features/playlists/components/add-to-playlist-context-modal.tsx @@ -6,6 +6,7 @@ import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; import { PlaylistListSort, SongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types'; import { Button, MultiSelect, Switch, toast } from '/@/renderer/components'; +import { getGenreSongsById } from '/@/renderer/features/player'; import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation'; import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query'; import { queryClient } from '/@/renderer/lib/react-query'; @@ -17,9 +18,10 @@ export const AddToPlaylistContextModal = ({ }: ContextModalProps<{ albumId?: string[]; artistId?: string[]; + genreId?: string[]; songId?: string[]; }>) => { - const { albumId, artistId, songId } = innerProps; + const { albumId, artistId, genreId, songId } = innerProps; const server = useCurrentServer(); const [isLoading, setIsLoading] = useState(false); @@ -112,6 +114,16 @@ export const AddToPlaylistContextModal = ({ } } + if (genreId && genreId.length > 0) { + const songs = await getGenreSongsById({ + id: genreId, + queryClient, + server, + }); + + allSongIds.push(...(songs?.items?.map((song) => song.id) || [])); + } + if (songId && songId.length > 0) { allSongIds.push(...songId); }