From 025124c3793bb45160f46bcf4bac92a8433e5da6 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 19 Feb 2024 08:53:50 -0800 Subject: [PATCH] [enhancement]: Make related tab on full screen player useful Resolves #50. Adds a new set of components for fetching similar songs from the current playing song. For Jellyfin, use the `/items/{itemId}/similar` endpoint (may not work well for small libraries), and for Navidrome/Subsonic use `getSimilarSongs`. _In theory_, this component can be used to get similar songs anywhere. --- src/renderer/api/controller.ts | 16 ++++ src/renderer/api/jellyfin/jellyfin-api.ts | 9 ++ .../api/jellyfin/jellyfin-controller.ts | 24 ++++++ src/renderer/api/jellyfin/jellyfin-types.ts | 12 +++ src/renderer/api/query-keys.ts | 5 ++ src/renderer/api/subsonic/subsonic-api.ts | 8 ++ .../api/subsonic/subsonic-controller.ts | 22 +++++ src/renderer/api/subsonic/subsonic-types.ts | 13 +++ src/renderer/api/types.ts | 9 ++ .../components/full-screen-player-queue.tsx | 23 ++---- .../components/full-screen-similar-songs.tsx | 13 +++ .../components/similar-songs-list.tsx | 82 +++++++++++++++++++ .../queries/similar-song-queries.tsx | 26 ++++++ .../features/songs/queries/song-list-query.ts | 1 + 14 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 src/renderer/features/player/components/full-screen-similar-songs.tsx create mode 100644 src/renderer/features/similar-songs/components/similar-songs-list.tsx create mode 100644 src/renderer/features/similar-songs/queries/similar-song-queries.tsx diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 6cd4d6235..05961b1e9 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -52,6 +52,8 @@ import type { ServerInfoArgs, StructuredLyricsArgs, StructuredLyric, + SimilarSongsArgs, + Song, } from '/@/renderer/api/types'; import { ServerType } from '/@/renderer/types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types'; @@ -90,6 +92,7 @@ export type ControllerEndpoint = Partial<{ getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; getRandomSongList: (args: RandomSongListArgs) => Promise; getServerInfo: (args: ServerInfoArgs) => Promise; + getSimilarSongs: (args: SimilarSongsArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; getStructuredLyrics: (args: StructuredLyricsArgs) => Promise; @@ -136,6 +139,7 @@ const endpoints: ApiController = { getPlaylistSongList: jfController.getPlaylistSongList, getRandomSongList: jfController.getRandomSongList, getServerInfo: jfController.getServerInfo, + getSimilarSongs: jfController.getSimilarSongs, getSongDetail: jfController.getSongDetail, getSongList: jfController.getSongList, getStructuredLyrics: undefined, @@ -174,6 +178,7 @@ const endpoints: ApiController = { getPlaylistSongList: ndController.getPlaylistSongList, getRandomSongList: ssController.getRandomSongList, getServerInfo: ssController.getServerInfo, + getSimilarSongs: ssController.getSimilarSongs, getSongDetail: ndController.getSongDetail, getSongList: ndController.getSongList, getStructuredLyrics: ssController.getStructuredLyrics, @@ -209,6 +214,7 @@ const endpoints: ApiController = { getPlaylistDetail: undefined, getPlaylistList: undefined, getServerInfo: ssController.getServerInfo, + getSimilarSongs: ssController.getSimilarSongs, getSongDetail: undefined, getSongList: undefined, getStructuredLyrics: ssController.getStructuredLyrics, @@ -511,6 +517,15 @@ const getStructuredLyrics = async (args: StructuredLyricsArgs) => { )?.(args); }; +const getSimilarSongs = async (args: SimilarSongsArgs) => { + return ( + apiController( + 'getSimilarSongs', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getSimilarSongs'] + )?.(args); +}; + export const controller = { addToPlaylist, authenticate, @@ -531,6 +546,7 @@ export const controller = { getPlaylistSongList, getRandomSongList, getServerInfo, + getSimilarSongs, getSongDetail, getSongList, getStructuredLyrics, diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index d9b3040dd..7b64f85c3 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -167,6 +167,15 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + getSimilarSongs: { + method: 'GET', + path: 'items/:itemId/similar', + query: jfType._parameters.similarSongs, + responses: { + 200: jfType._response.similarSongs, + 400: jfType._response.error, + }, + }, getSongDetail: { method: 'GET', path: 'users/:userId/items/:id', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 1d1339cea..b31c2551c 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -51,6 +51,8 @@ import { SongDetailResponse, ServerInfo, ServerInfoArgs, + SimilarSongsArgs, + Song, } from '/@/renderer/api/types'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfNormalize } from './jellyfin-normalize'; @@ -960,6 +962,27 @@ const getServerInfo = async (args: ServerInfoArgs): Promise => { return { id: apiClientProps.server?.id, version: res.body.Version }; }; +const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { + const { apiClientProps, query } = args; + + const res = await jfApiClient(apiClientProps).getSimilarSongs({ + params: { + itemId: query.song.id, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, ParentId', + Limit: query.count, + UserId: apiClientProps.server?.userId || undefined, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get music folder list'); + } + + return res.body.Items.map((song) => jfNormalize.song(song, apiClientProps.server, '')); +}; + export const jfController = { addToPlaylist, authenticate, @@ -980,6 +1003,7 @@ export const jfController = { getPlaylistSongList, getRandomSongList, getServerInfo, + getSimilarSongs, getSongDetail, getSongList, getTopSongList, diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index 3ce22e371..9b9e53331 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -665,6 +665,16 @@ const serverInfo = z.object({ Version: z.string(), }); +const similarSongsParameters = z.object({ + Fields: z.string().optional(), + Limit: z.number().optional(), + UserId: z.string().optional(), +}); + +const similarSongs = pagination.extend({ + Items: z.array(song), +}); + export const jfType = { _enum: { albumArtistList: albumArtistListSort, @@ -694,6 +704,7 @@ export const jfType = { scrobble: scrobbleParameters, search: searchParameters, similarArtistList: similarArtistListParameters, + similarSongs: similarSongsParameters, songList: songListParameters, updatePlaylist: updatePlaylistParameters, }, @@ -719,6 +730,7 @@ export const jfType = { scrobble, search, serverInfo, + similarSongs, song, songList, topSongsList, diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 30433e461..3706bf0a5 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -18,6 +18,7 @@ import type { LyricsQuery, LyricSearchQuery, GenreListQuery, + SimilarSongsQuery, } from './types'; export const splitPaginatedQuery = (key: any) => { @@ -239,6 +240,10 @@ export const queryKeys: Record< return [serverId, 'songs', 'randomSongList'] as const; }, root: (serverId: string) => [serverId, 'songs'] as const, + similar: (serverId: string, query?: SimilarSongsQuery) => { + if (query) return [serverId, 'song', 'similar', query] as const; + return [serverId, 'song', 'similar'] as const; + }, }, users: { list: (serverId: string, query?: UserListQuery) => { diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 757575171..467c7efcb 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -57,6 +57,14 @@ export const contract = c.router({ 200: ssType._response.serverInfo, }, }, + getSimilarSongs: { + method: 'GET', + path: 'getSimilarSongs', + query: ssType._parameters.similarSongs, + responses: { + 200: ssType._response.similarSongs, + }, + }, getStructuredLyrics: { method: 'GET', path: 'getLyricsBySongId.view', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index b67200e8b..60c0138e1 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -25,6 +25,8 @@ import { ServerInfoArgs, StructuredLyricsArgs, StructuredLyric, + SimilarSongsArgs, + Song, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; @@ -444,6 +446,25 @@ export const getStructuredLyrics = async ( }); }; +const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { + const { apiClientProps, query } = args; + + const res = await ssApiClient(apiClientProps).getSimilarSongs({ + query: { + count: query.count, + id: query.song.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get music folder list'); + } + + return res.body.similarSongs.song.map((song) => + ssNormalize.song(song, apiClientProps.server, ''), + ); +}; + export const ssController = { authenticate, createFavorite, @@ -451,6 +472,7 @@ export const ssController = { getMusicFolderList, getRandomSongList, getServerInfo, + getSimilarSongs, getStructuredLyrics, getTopSongList, removeFavorite, diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 9005fe8c5..b54b9eb18 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -247,6 +247,17 @@ const structuredLyrics = z.object({ .optional(), }); +const similarSongsParameters = z.object({ + count: z.number().optional(), + id: z.string(), +}); + +const similarSongs = z.object({ + similarSongs: z.object({ + song: z.array(song), + }), +}); + export const ssType = { _parameters: { albumList: albumListParameters, @@ -258,6 +269,7 @@ export const ssType = { scrobble: scrobbleParameters, search3: search3Parameters, setRating: setRatingParameters, + similarSongs: similarSongsParameters, structuredLyrics: structuredLyricsParameters, topSongsList: topSongsListParameters, }, @@ -278,6 +290,7 @@ export const ssType = { search3, serverInfo, setRating, + similarSongs, song, structuredLyrics, topSongsList, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index c9231ba0d..8aff013bd 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1170,3 +1170,12 @@ export type StructuredSyncedLyric = { export type StructuredLyric = { lang: string; } & (StructuredUnsyncedLyric | StructuredSyncedLyric); + +export type SimilarSongsQuery = { + count?: number; + song: Song; +}; + +export type SimilarSongsArgs = { + query: SimilarSongsQuery; +} & BaseEndpointArgs; diff --git a/src/renderer/features/player/components/full-screen-player-queue.tsx b/src/renderer/features/player/components/full-screen-player-queue.tsx index 031c4ea28..0c0347f01 100644 --- a/src/renderer/features/player/components/full-screen-player-queue.tsx +++ b/src/renderer/features/player/components/full-screen-player-queue.tsx @@ -1,16 +1,17 @@ -import { Group, Center } from '@mantine/core'; +import { Group } from '@mantine/core'; import { motion } from 'framer-motion'; import { useTranslation } from 'react-i18next'; import { HiOutlineQueueList } from 'react-icons/hi2'; -import { RiFileMusicLine, RiFileTextLine, RiInformationFill } from 'react-icons/ri'; +import { RiFileMusicLine, RiFileTextLine } from 'react-icons/ri'; import styled from 'styled-components'; -import { Button, TextTitle } from '/@/renderer/components'; +import { Button } from '/@/renderer/components'; import { PlayQueue } from '/@/renderer/features/now-playing'; import { useFullScreenPlayerStore, useFullScreenPlayerStoreActions, } from '/@/renderer/store/full-screen-player.store'; import { Lyrics } from '/@/renderer/features/lyrics/lyrics'; +import { FullScreenSimilarSongs } from '/@/renderer/features/player/components/full-screen-similar-songs'; const QueueContainer = styled.div` position: relative; @@ -82,8 +83,6 @@ export const FullScreenPlayerQueue = () => { }, ]; - console.log('opacity', opacity); - return ( { ) : activeTab === 'related' ? ( -
- - - - {t('common.comingSoon', { postProcess: 'upperCase' })} - - -
+ + + ) : activeTab === 'lyrics' ? ( ) : null} diff --git a/src/renderer/features/player/components/full-screen-similar-songs.tsx b/src/renderer/features/player/components/full-screen-similar-songs.tsx new file mode 100644 index 000000000..16f966282 --- /dev/null +++ b/src/renderer/features/player/components/full-screen-similar-songs.tsx @@ -0,0 +1,13 @@ +import { SimilarSongsList } from '/@/renderer/features/similar-songs/components/similar-songs-list'; +import { useCurrentSong } from '/@/renderer/store'; + +export const FullScreenSimilarSongs = () => { + const currentSong = useCurrentSong(); + + return ( + + ); +}; diff --git a/src/renderer/features/similar-songs/components/similar-songs-list.tsx b/src/renderer/features/similar-songs/components/similar-songs-list.tsx new file mode 100644 index 000000000..f319476c3 --- /dev/null +++ b/src/renderer/features/similar-songs/components/similar-songs-list.tsx @@ -0,0 +1,82 @@ +import { ErrorBoundary } from 'react-error-boundary'; +import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid'; +import { VirtualTable, getColumnDefs } from '/@/renderer/components/virtual-table'; +import { ErrorFallback } from '/@/renderer/features/action-required'; +import { useSimilarSongs } from '/@/renderer/features/similar-songs/queries/similar-song-queries'; +import { usePlayButtonBehavior, useTableSettings } from '/@/renderer/store'; +import { useMemo, useRef } from 'react'; +import { AgGridReact } from '@ag-grid-community/react'; +import { LibraryItem, Song } from '/@/renderer/api/types'; +import { useHandleTableContextMenu } from '/@/renderer/features/context-menu'; +import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items'; +import { Spinner } from '/@/renderer/components'; +import { RowDoubleClickedEvent } from '@ag-grid-community/core'; +import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add'; + +export type SimilarSongsListProps = { + count?: number; + fullScreen?: boolean; + song?: Song; +}; + +export const SimilarSongsList = ({ count, fullScreen, song }: SimilarSongsListProps) => { + const tableRef = useRef | null>(null); + const tableConfig = useTableSettings(fullScreen ? 'fullScreen' : 'songs'); + const handlePlayQueueAdd = useHandlePlayQueueAdd(); + const playButtonBehavior = usePlayButtonBehavior(); + + const songQuery = useSimilarSongs({ + options: { + cacheTime: 1000 * 60 * 2, + staleTime: 1000 * 60 * 1, + }, + query: { count, song }, + serverId: undefined, + }); + + const columnDefs = useMemo( + () => getColumnDefs(tableConfig.columns, false, 'generic'), + [tableConfig.columns], + ); + + const onCellContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS); + + const handleRowDoubleClick = (e: RowDoubleClickedEvent) => { + if (!e.data || !songQuery.data) return; + + handlePlayQueueAdd?.({ + byData: songQuery.data, + initialSongId: e.data.id, + playType: playButtonBehavior, + }); + }; + + return songQuery.isLoading ? ( + + ) : ( + + + data.data.uniqueId} + rowBuffer={50} + rowData={songQuery.data} + rowHeight={tableConfig.rowHeight || 40} + onCellContextMenu={onCellContextMenu} + onCellDoubleClicked={handleRowDoubleClick} + /> + + + ); +}; diff --git a/src/renderer/features/similar-songs/queries/similar-song-queries.tsx b/src/renderer/features/similar-songs/queries/similar-song-queries.tsx new file mode 100644 index 000000000..ead70817f --- /dev/null +++ b/src/renderer/features/similar-songs/queries/similar-song-queries.tsx @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; +import { SimilarSongsQuery } from '/@/renderer/api/types'; +import { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { getServerById } from '/@/renderer/store'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { api } from '/@/renderer/api'; + +export const useSimilarSongs = (args: QueryHookArgs>) => { + const { options, query } = args || {}; + const server = getServerById(query.song?.serverId); + + return useQuery({ + enabled: !!server?.id && !!query.song, + queryFn: ({ signal }) => { + if (!server) throw new Error('Server not found'); + if (!query.song) return undefined; + + return api.controller.getSimilarSongs({ + apiClientProps: { server, signal }, + query: { count: query.count ?? 50, song: query.song }, + }); + }, + queryKey: queryKeys.albumArtists.detail(server?.id || '', query), + ...options, + }); +}; diff --git a/src/renderer/features/songs/queries/song-list-query.ts b/src/renderer/features/songs/queries/song-list-query.ts index c63d06c51..d5ab5271f 100644 --- a/src/renderer/features/songs/queries/song-list-query.ts +++ b/src/renderer/features/songs/queries/song-list-query.ts @@ -10,6 +10,7 @@ export const useSongList = (args: QueryHookArgs, imageSize?: numb const server = getServerById(serverId); return useQuery({ + cacheTime: 1000 * 60, enabled: !!server?.id, queryFn: ({ signal }) => { if (!server) throw new Error('Server not found');