diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 6efc6b2a..de76e721 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -52,6 +52,8 @@ import type { ServerInfoArgs, StructuredLyricsArgs, StructuredLyric, + SimilarSongsArgs, + Song, ServerType, } from '/@/renderer/api/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: ndController.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 8147a4d8..ed9a538e 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 385c6ef3..6ef4a7c9 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'; @@ -970,6 +972,33 @@ const getServerInfo = async (args: ServerInfoArgs): Promise => { }; }; +const getSimilarSongs = async (args: SimilarSongsArgs): Promise => { + const { apiClientProps, query } = args; + + const res = await jfApiClient(apiClientProps).getSimilarSongs({ + params: { + itemId: query.songId, + }, + query: { + Fields: 'Genres, DateCreated, MediaSources, ParentId', + Limit: query.count, + UserId: apiClientProps.server?.userId || undefined, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get similar songs'); + } + + return res.body.Items.reduce((acc, song) => { + if (song.Id !== query.songId) { + acc.push(jfNormalize.song(song, apiClientProps.server, '')); + } + + return acc; + }, []); +}; + export const jfController = { addToPlaylist, authenticate, @@ -990,6 +1019,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 8ed18b8c..683a2eac 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 enum JellyfinExtensions { SONG_LYRICS = 'songLyrics', } @@ -698,6 +708,7 @@ export const jfType = { scrobble: scrobbleParameters, search: searchParameters, similarArtistList: similarArtistListParameters, + similarSongs: similarSongsParameters, songList: songListParameters, updatePlaylist: updatePlaylistParameters, }, @@ -723,6 +734,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 30433e46..3706bf0a 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 75757517..467c7efc 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 457672de..7611f0e2 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'; import { ServerFeatures } from '/@/renderer/api/features.types'; @@ -454,6 +456,33 @@ 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.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get similar songs'); + } + + if (!res.body.similarSongs) { + return []; + } + + return res.body.similarSongs.song.reduce((acc, song) => { + if (song.id !== query.songId) { + acc.push(ssNormalize.song(song, apiClientProps.server, '')); + } + + return acc; + }, []); +}; + export const ssController = { authenticate, createFavorite, @@ -461,6 +490,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 b113446f..50bbdbf2 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -247,12 +247,26 @@ 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), + }) + .optional(), +}); + export enum SubsonicExtensions { FORM_POST = 'formPost', SONG_LYRICS = 'songLyrics', TRANSCODE_OFFSET = 'transcodeOffset', } + export const ssType = { _parameters: { albumList: albumListParameters, @@ -264,6 +278,7 @@ export const ssType = { scrobble: scrobbleParameters, search3: search3Parameters, setRating: setRatingParameters, + similarSongs: similarSongsParameters, structuredLyrics: structuredLyricsParameters, topSongsList: topSongsListParameters, }, @@ -284,6 +299,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 7001d179..f3e637a2 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1168,3 +1168,12 @@ export type StructuredSyncedLyric = { export type StructuredLyric = { lang: string; } & (StructuredUnsyncedLyric | StructuredSyncedLyric); + +export type SimilarSongsQuery = { + count?: number; + songId: string; +}; + +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 f4d1a022..927773cd 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; @@ -121,17 +122,9 @@ export const FullScreenPlayerQueue = () => { ) : 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 00000000..9aa31c95 --- /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 currentSong?.id ? ( + + ) : null; +}; 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 00000000..1c1dc678 --- /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, songId: song.id }, + serverId: song?.serverId, + }); + + 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 00000000..0ba4516a --- /dev/null +++ b/src/renderer/features/similar-songs/queries/similar-song-queries.tsx @@ -0,0 +1,25 @@ +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, serverId } = args || {}; + const server = getServerById(serverId); + + return useQuery({ + enabled: !!server, + queryFn: ({ signal }) => { + if (!server) throw new Error('Server not found'); + + return api.controller.getSimilarSongs({ + apiClientProps: { server, signal }, + query: { count: query.count ?? 50, songId: query.songId }, + }); + }, + queryKey: queryKeys.albumArtists.detail(server?.id || '', query), + ...options, + }); +};