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,
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<UserListResponse>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
}>;
@ -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,
};

View file

@ -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,
},

View file

@ -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,

View file

@ -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<ScrobbleResponse> => {
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 = {
authenticate,
createFavorite,
@ -313,5 +349,6 @@ export const ssController = {
getTopSongList,
removeFavorite,
scrobble,
search3,
setRating,
};

View file

@ -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<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 = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
song: normalizeSong,
};

View file

@ -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,

View file

@ -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[];
};

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,
});
};