Add subsonic/nd search api

This commit is contained in:
jeffvli 2023-05-19 00:14:41 -07:00 committed by Jeff
parent c85a7079eb
commit 32ebe6b739
8 changed files with 224 additions and 5 deletions

View file

@ -44,6 +44,8 @@ import type {
UpdatePlaylistResponse, UpdatePlaylistResponse,
UserListResponse, UserListResponse,
AuthenticationResponse, AuthenticationResponse,
SearchArgs,
SearchResponse,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { ServerType } from '/@/renderer/types'; import { ServerType } from '/@/renderer/types';
import { DeletePlaylistResponse } from './types'; import { DeletePlaylistResponse } from './types';
@ -84,6 +86,7 @@ export type ControllerEndpoint = Partial<{
getUserList: (args: UserListArgs) => Promise<UserListResponse>; getUserList: (args: UserListArgs) => Promise<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>; scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>; setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>; updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>; }>;
@ -125,6 +128,7 @@ const endpoints: ApiController = {
getUserList: undefined, getUserList: undefined,
removeFromPlaylist: jfController.removeFromPlaylist, removeFromPlaylist: jfController.removeFromPlaylist,
scrobble: jfController.scrobble, scrobble: jfController.scrobble,
search: undefined,
setRating: undefined, setRating: undefined,
updatePlaylist: jfController.updatePlaylist, updatePlaylist: jfController.updatePlaylist,
}, },
@ -158,6 +162,7 @@ const endpoints: ApiController = {
getUserList: ndController.getUserList, getUserList: ndController.getUserList,
removeFromPlaylist: ndController.removeFromPlaylist, removeFromPlaylist: ndController.removeFromPlaylist,
scrobble: ssController.scrobble, scrobble: ssController.scrobble,
search: ssController.search3,
setRating: ssController.setRating, setRating: ssController.setRating,
updatePlaylist: ndController.updatePlaylist, updatePlaylist: ndController.updatePlaylist,
}, },
@ -188,6 +193,7 @@ const endpoints: ApiController = {
getTopSongs: ssController.getTopSongList, getTopSongs: ssController.getTopSongList,
getUserList: undefined, getUserList: undefined,
scrobble: ssController.scrobble, scrobble: ssController.scrobble,
search: ssController.search3,
setRating: undefined, setRating: undefined,
updatePlaylist: undefined, updatePlaylist: undefined,
}, },
@ -198,17 +204,18 @@ const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) =>
if (!serverType) { if (!serverType) {
toast.error({ message: 'No server selected', title: 'Unable to route request' }); 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') { if (typeof controllerFn !== 'function') {
toast.error({ toast.error({
message: `Endpoint ${endpoint} is not implemented for ${serverType}`, message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
title: 'Unable to route request', title: 'Unable to route request',
}); });
return () => undefined;
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
} }
return endpoints[serverType][endpoint]; return endpoints[serverType][endpoint];
@ -414,6 +421,12 @@ const scrobble = async (args: ScrobbleArgs) => {
)?.(args); )?.(args);
}; };
const search = async (args: SearchArgs) => {
return (
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
)?.(args);
};
export const controller = { export const controller = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
@ -436,6 +449,7 @@ export const controller = {
getUserList, getUserList,
removeFromPlaylist, removeFromPlaylist,
scrobble, scrobble,
search,
updatePlaylist, updatePlaylist,
updateRating, updateRating,
}; };

View file

@ -10,6 +10,7 @@ import type {
UserListQuery, UserListQuery,
AlbumArtistDetailQuery, AlbumArtistDetailQuery,
TopSongListQuery, TopSongListQuery,
SearchQuery,
} from './types'; } from './types';
export const queryKeys = { export const queryKeys = {
@ -76,6 +77,13 @@ export const queryKeys = {
return [serverId, 'playlists', 'songList'] as const; 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: { server: {
root: (serverId: string) => [serverId] as const, root: (serverId: string) => [serverId] as const,
}, },

View file

@ -65,6 +65,14 @@ export const contract = c.router({
200: ssType._response.scrobble, 200: ssType._response.scrobble,
}, },
}, },
search3: {
method: 'GET',
path: 'search3.view',
query: ssType._parameters.search3,
responses: {
200: ssType._response.search3,
},
},
setRating: { setRating: {
method: 'GET', method: 'GET',
path: 'setRating.view', path: 'setRating.view',
@ -165,9 +173,14 @@ export const ssApiClient = (args: {
status: result.status, status: result.status,
}; };
} catch (e: Error | AxiosError | any) { } catch (e: Error | AxiosError | any) {
console.log('CATCH ERR');
if (isAxiosError(e)) { if (isAxiosError(e)) {
const error = e as AxiosError; const error = e as AxiosError;
const response = error.response as AxiosResponse; const response = error.response as AxiosResponse;
console.log(response, 'response');
return { return {
body: response?.data, body: response?.data,
status: response?.status, status: response?.status,

View file

@ -17,6 +17,8 @@ import {
ScrobbleResponse, ScrobbleResponse,
SongListResponse, SongListResponse,
TopSongListArgs, TopSongListArgs,
SearchArgs,
SearchResponse,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { randomString } from '/@/renderer/utils'; import { randomString } from '/@/renderer/utils';
@ -305,6 +307,40 @@ const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
return null; return null;
}; };
const search3 = async (args: SearchArgs): Promise<SearchResponse> => {
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 = { export const ssController = {
authenticate, authenticate,
createFavorite, createFavorite,
@ -313,5 +349,6 @@ export const ssController = {
getTopSongList, getTopSongList,
removeFavorite, removeFavorite,
scrobble, scrobble,
search3,
setRating, setRating,
}; };

View file

@ -1,7 +1,7 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { z } from 'zod'; import { z } from 'zod';
import { ssType } from '/@/renderer/api/subsonic/subsonic-types'; 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'; import { ServerListItem, ServerType } from '/@/renderer/types';
const getCoverArtUrl = (args: { const getCoverArtUrl = (args: {
@ -10,7 +10,7 @@ const getCoverArtUrl = (args: {
credential: string | undefined; credential: string | undefined;
size: number; size: number;
}) => { }) => {
const size = args.size ? args.size : 150; const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) { if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null; return null;
@ -98,6 +98,82 @@ const normalizeSong = (
}; };
}; };
const normalizeAlbumArtist = (
item: z.infer<typeof ssType._response.albumArtist>,
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<typeof ssType._response.album>,
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 = { export const ssNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
song: normalizeSong, song: normalizeSong,
}; };

View file

@ -173,6 +173,25 @@ const scrobbleParameters = z.object({
const scrobble = z.null(); 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 = { export const ssType = {
_parameters: { _parameters: {
albumList: albumListParameters, albumList: albumListParameters,
@ -181,10 +200,13 @@ export const ssType = {
createFavorite: createFavoriteParameters, createFavorite: createFavoriteParameters,
removeFavorite: removeFavoriteParameters, removeFavorite: removeFavoriteParameters,
scrobble: scrobbleParameters, scrobble: scrobbleParameters,
search3: search3Parameters,
setRating: setRatingParameters, setRating: setRatingParameters,
topSongsList: topSongsListParameters, topSongsList: topSongsListParameters,
}, },
_response: { _response: {
album,
albumArtist,
albumArtistList, albumArtistList,
albumList, albumList,
artistInfo, artistInfo,
@ -194,6 +216,7 @@ export const ssType = {
musicFolderList, musicFolderList,
removeFavorite, removeFavorite,
scrobble, scrobble,
search3,
setRating, setRating,
song, song,
topSongsList, topSongsList,

View file

@ -959,3 +959,24 @@ export type ScrobbleQuery = {
position?: number; position?: number;
submission: boolean; 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[];
};

View file

@ -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<SearchQuery>) => {
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,
});
};