From 32ebe6b739c706d02f6018bc971faf75e2e21dd1 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 19 May 2023 00:14:41 -0700 Subject: [PATCH] Add subsonic/nd search api --- src/renderer/api/controller.ts | 20 ++++- src/renderer/api/query-keys.ts | 8 ++ src/renderer/api/subsonic/subsonic-api.ts | 13 +++ .../api/subsonic/subsonic-controller.ts | 37 +++++++++ .../api/subsonic/subsonic-normalize.ts | 80 ++++++++++++++++++- src/renderer/api/subsonic/subsonic-types.ts | 23 ++++++ src/renderer/api/types.ts | 21 +++++ .../features/search/queries/search-query.ts | 27 +++++++ 8 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 src/renderer/features/search/queries/search-query.ts diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 7f3bdd87..923880f5 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -44,6 +44,8 @@ import type { UpdatePlaylistResponse, UserListResponse, AuthenticationResponse, + SearchArgs, + SearchResponse, } from '/@/renderer/api/types'; import { ServerType } from '/@/renderer/types'; import { DeletePlaylistResponse } from './types'; @@ -84,6 +86,7 @@ export type ControllerEndpoint = Partial<{ getUserList: (args: UserListArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; scrobble: (args: ScrobbleArgs) => Promise; + search: (args: SearchArgs) => Promise; setRating: (args: SetRatingArgs) => Promise; updatePlaylist: (args: UpdatePlaylistArgs) => Promise; }>; @@ -125,6 +128,7 @@ const endpoints: ApiController = { getUserList: undefined, removeFromPlaylist: jfController.removeFromPlaylist, scrobble: jfController.scrobble, + search: undefined, setRating: undefined, updatePlaylist: jfController.updatePlaylist, }, @@ -158,6 +162,7 @@ const endpoints: ApiController = { getUserList: ndController.getUserList, removeFromPlaylist: ndController.removeFromPlaylist, scrobble: ssController.scrobble, + search: ssController.search3, setRating: ssController.setRating, updatePlaylist: ndController.updatePlaylist, }, @@ -188,6 +193,7 @@ const endpoints: ApiController = { getTopSongs: ssController.getTopSongList, getUserList: undefined, scrobble: ssController.scrobble, + search: ssController.search3, setRating: undefined, updatePlaylist: undefined, }, @@ -198,17 +204,18 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => if (!serverType) { toast.error({ message: 'No server selected', title: 'Unable to route request' }); - return () => undefined; + throw new Error(`No server selected`); } - const controllerFn = endpoints[serverType][endpoint]; + const controllerFn = endpoints?.[serverType]?.[endpoint]; if (typeof controllerFn !== 'function') { toast.error({ message: `Endpoint ${endpoint} is not implemented for ${serverType}`, title: 'Unable to route request', }); - return () => undefined; + + throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`); } return endpoints[serverType][endpoint]; @@ -414,6 +421,12 @@ const scrobble = async (args: ScrobbleArgs) => { )?.(args); }; +const search = async (args: SearchArgs) => { + return ( + apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search'] + )?.(args); +}; + export const controller = { addToPlaylist, authenticate, @@ -436,6 +449,7 @@ export const controller = { getUserList, removeFromPlaylist, scrobble, + search, updatePlaylist, updateRating, }; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 8c7765b8..1c3d9c01 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -10,6 +10,7 @@ import type { UserListQuery, AlbumArtistDetailQuery, TopSongListQuery, + SearchQuery, } from './types'; export const queryKeys = { @@ -76,6 +77,13 @@ export const queryKeys = { return [serverId, 'playlists', 'songList'] as const; }, }, + search: { + list: (serverId: string, query?: SearchQuery) => { + if (query) return [serverId, 'search', 'list', query] as const; + return [serverId, 'search', 'list'] as const; + }, + root: (serverId: string) => [serverId, 'search'] as const, + }, server: { root: (serverId: string) => [serverId] as const, }, diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index c247ef95..fb908f47 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -65,6 +65,14 @@ export const contract = c.router({ 200: ssType._response.scrobble, }, }, + search3: { + method: 'GET', + path: 'search3.view', + query: ssType._parameters.search3, + responses: { + 200: ssType._response.search3, + }, + }, setRating: { method: 'GET', path: 'setRating.view', @@ -165,9 +173,14 @@ export const ssApiClient = (args: { status: result.status, }; } catch (e: Error | AxiosError | any) { + console.log('CATCH ERR'); + if (isAxiosError(e)) { const error = e as AxiosError; const response = error.response as AxiosResponse; + + console.log(response, 'response'); + return { body: response?.data, status: response?.status, diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index e54adc24..d17231ce 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -17,6 +17,8 @@ import { ScrobbleResponse, SongListResponse, TopSongListArgs, + SearchArgs, + SearchResponse, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; @@ -305,6 +307,40 @@ const scrobble = async (args: ScrobbleArgs): Promise => { return null; }; +const search3 = async (args: SearchArgs): Promise => { + const { query, apiClientProps } = args; + + console.log('search api'); + + const res = await ssApiClient(apiClientProps).search3({ + query: { + albumCount: query.albumLimit, + albumOffset: query.albumStartIndex, + artistCount: query.albumArtistLimit, + artistOffset: query.albumArtistStartIndex, + query: query.query, + songCount: query.songLimit, + songOffset: query.songStartIndex, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to search'); + } + + return { + albumArtists: res.body.searchResult3?.artist?.map((artist) => + ssNormalize.albumArtist(artist, apiClientProps.server), + ), + albums: res.body.searchResult3?.album?.map((album) => + ssNormalize.album(album, apiClientProps.server), + ), + songs: res.body.searchResult3?.song?.map((song) => + ssNormalize.song(song, apiClientProps.server, ''), + ), + }; +}; + export const ssController = { authenticate, createFavorite, @@ -313,5 +349,6 @@ export const ssController = { getTopSongList, removeFavorite, scrobble, + search3, setRating, }; diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index ffc10ad9..53e4bc6b 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -1,7 +1,7 @@ import { nanoid } from 'nanoid'; import { z } from 'zod'; import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; -import { QueueSong, LibraryItem } from '/@/renderer/api/types'; +import { QueueSong, LibraryItem, AlbumArtist, Album } from '/@/renderer/api/types'; import { ServerListItem, ServerType } from '/@/renderer/types'; const getCoverArtUrl = (args: { @@ -10,7 +10,7 @@ const getCoverArtUrl = (args: { credential: string | undefined; size: number; }) => { - const size = args.size ? args.size : 150; + const size = args.size ? args.size : 250; if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) { return null; @@ -98,6 +98,82 @@ const normalizeSong = ( }; }; +const normalizeAlbumArtist = ( + item: z.infer, + server: ServerListItem | null, +): AlbumArtist => { + const imageUrl = + getCoverArtUrl({ + baseUrl: server?.url, + coverArtId: item.coverArt, + credential: server?.credential, + size: 100, + }) || null; + + return { + albumCount: item.albumCount ? Number(item.albumCount) : 0, + backgroundImageUrl: null, + biography: null, + duration: null, + genres: [], + id: item.id, + imageUrl, + itemType: LibraryItem.ALBUM_ARTIST, + lastPlayedAt: null, + name: item.name, + playCount: null, + serverId: server?.id || 'unknown', + serverType: ServerType.SUBSONIC, + similarArtists: [], + songCount: null, + userFavorite: false, + userRating: null, + }; +}; + +const normalizeAlbum = ( + item: z.infer, + server: ServerListItem | null, +): Album => { + const imageUrl = + getCoverArtUrl({ + baseUrl: server?.url, + coverArtId: item.coverArt, + credential: server?.credential, + size: 300, + }) || null; + + return { + albumArtists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [], + artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [], + backdropImageUrl: null, + createdAt: item.created, + duration: item.duration, + genres: item.genre ? [{ id: item.genre, name: item.genre }] : [], + id: item.id, + imagePlaceholderUrl: null, + imageUrl, + isCompilation: null, + itemType: LibraryItem.ALBUM, + lastPlayedAt: null, + name: item.name, + playCount: null, + releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null, + releaseYear: item.year ? Number(item.year) : null, + serverId: server?.id || 'unknown', + serverType: ServerType.SUBSONIC, + size: null, + songCount: item.songCount, + songs: [], + uniqueId: nanoid(), + updatedAt: item.created, + userFavorite: item.starred || false, + userRating: item.userRating || null, + }; +}; + export const ssNormalize = { + album: normalizeAlbum, + albumArtist: normalizeAlbumArtist, song: normalizeSong, }; diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 8f38fdd8..e6d412c5 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -173,6 +173,25 @@ const scrobbleParameters = z.object({ const scrobble = z.null(); +const search3 = z.object({ + searchResult3: z.object({ + album: z.array(album), + artist: z.array(albumArtist), + song: z.array(song), + }), +}); + +const search3Parameters = z.object({ + albumCount: z.number().optional(), + albumOffset: z.number().optional(), + artistCount: z.number().optional(), + artistOffset: z.number().optional(), + musicFolderId: z.string().optional(), + query: z.string().optional(), + songCount: z.number().optional(), + songOffset: z.number().optional(), +}); + export const ssType = { _parameters: { albumList: albumListParameters, @@ -181,10 +200,13 @@ export const ssType = { createFavorite: createFavoriteParameters, removeFavorite: removeFavoriteParameters, scrobble: scrobbleParameters, + search3: search3Parameters, setRating: setRatingParameters, topSongsList: topSongsListParameters, }, _response: { + album, + albumArtist, albumArtistList, albumList, artistInfo, @@ -194,6 +216,7 @@ export const ssType = { musicFolderList, removeFavorite, scrobble, + search3, setRating, song, topSongsList, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 3e79dec1..5d3a4f30 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -959,3 +959,24 @@ export type ScrobbleQuery = { position?: number; submission: boolean; }; + +export type SearchQuery = { + albumArtistLimit?: number; + albumArtistStartIndex?: number; + albumLimit?: number; + albumStartIndex?: number; + musicFolderId?: string; + query?: string; + songLimit?: number; + songStartIndex?: number; +}; + +export type SearchArgs = { + query: SearchQuery; +} & BaseEndpointArgs; + +export type SearchResponse = { + albumArtists: AlbumArtist[]; + albums: Album[]; + songs: Song[]; +}; diff --git a/src/renderer/features/search/queries/search-query.ts b/src/renderer/features/search/queries/search-query.ts new file mode 100644 index 00000000..a121626d --- /dev/null +++ b/src/renderer/features/search/queries/search-query.ts @@ -0,0 +1,27 @@ +import { SearchQuery } from '/@/renderer/api/types'; +import { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { getServerById } from '/@/renderer/store'; +import { api } from '/@/renderer/api'; + +export const useSearch = (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.search({ + apiClientProps: { + server, + signal, + }, + query, + }); + }, + queryKey: queryKeys.search.list(serverId || '', query), + ...options, + }); +};