Add subsonic/nd search api
This commit is contained in:
parent
c85a7079eb
commit
32ebe6b739
8 changed files with 224 additions and 5 deletions
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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[];
|
||||||
|
};
|
||||||
|
|
27
src/renderer/features/search/queries/search-query.ts
Normal file
27
src/renderer/features/search/queries/search-query.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
Reference in a new issue