mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
add getTranscodeDecision controller endpoint and types
This commit is contained in:
@@ -769,6 +769,20 @@ export const controller: GeneralController = {
|
|||||||
server.type,
|
server.type,
|
||||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||||
},
|
},
|
||||||
|
getTranscodeDecision(args) {
|
||||||
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(
|
||||||
|
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getTranscodeDecision`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiController(
|
||||||
|
'getTranscodeDecision',
|
||||||
|
server.type,
|
||||||
|
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||||
|
},
|
||||||
getUserInfo(args) {
|
getUserInfo(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -1284,39 +1284,34 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
query: { ...query, limit: 1, startIndex: 0 },
|
query: { ...query, limit: 1, startIndex: 0 },
|
||||||
}).then((result) => result!.totalRecordCount!),
|
}).then((result) => result!.totalRecordCount!),
|
||||||
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
||||||
const { bitrate, format, id, transcode } = query;
|
const { bitrate, format, id, offset = 0, transcode, transcodeParams } = query;
|
||||||
const deviceId = '';
|
const deviceId = '';
|
||||||
|
|
||||||
let url = `${server?.url}/Items/${id}/Download?apiKey=${server?.credential}&playSessionId=${deviceId}`;
|
if (transcodeParams != null || transcode) {
|
||||||
|
|
||||||
if (transcode) {
|
|
||||||
// Some format appears to be required. Fall back to trusty MP3 if not specified
|
|
||||||
// Otherwise, ffmpeg appears to crash
|
|
||||||
const realFormat = format || 'mp3';
|
const realFormat = format || 'mp3';
|
||||||
|
let url =
|
||||||
url =
|
|
||||||
`${server?.url}/audio` +
|
`${server?.url}/audio` +
|
||||||
`/${id}/universal` +
|
`/${id}/universal` +
|
||||||
`?userId=${server?.userId}` +
|
`?userId=${server?.userId}` +
|
||||||
`&deviceId=${deviceId}` +
|
`&deviceId=${deviceId}` +
|
||||||
'&audioCodec=aac' +
|
|
||||||
`&apiKey=${server?.credential}` +
|
`&apiKey=${server?.credential}` +
|
||||||
`&playSessionId=${deviceId}` +
|
`&playSessionId=${deviceId}` +
|
||||||
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg';
|
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg';
|
||||||
|
|
||||||
url += `&transcodingProtocol=http&transcodingContainer=${realFormat}`;
|
url += `&transcodingProtocol=http&transcodingContainer=${realFormat}`;
|
||||||
url = url.replace('audioCodec=aac', `audioCodec=${realFormat}`);
|
|
||||||
url = url.replace(
|
url = url.replace(
|
||||||
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg',
|
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg',
|
||||||
`&container=${realFormat}`,
|
`&container=${realFormat}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (bitrate !== undefined) {
|
if (bitrate !== undefined) {
|
||||||
url += `&maxStreamingBitrate=${bitrate * 1000}`;
|
url += `&maxStreamingBitrate=${bitrate * 1000}`;
|
||||||
}
|
}
|
||||||
|
if (offset > 0) {
|
||||||
|
url += `&startTimeTicks=${Math.floor(offset * 10_000_000)}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
return `${server?.url}/Items/${id}/Download?apiKey=${server?.credential}&playSessionId=${deviceId}`;
|
||||||
},
|
},
|
||||||
getTagList: async (args) => {
|
getTagList: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|||||||
@@ -941,6 +941,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: res.totalRecordCount,
|
totalRecordCount: res.totalRecordCount,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
getTranscodeDecision: SubsonicController.getTranscodeDecision,
|
||||||
getUserInfo: SubsonicController.getUserInfo,
|
getUserInfo: SubsonicController.getUserInfo,
|
||||||
getUserList: async (args) => {
|
getUserList: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|||||||
@@ -250,6 +250,15 @@ export const contract = c.router({
|
|||||||
200: ssType._response.topSongsList,
|
200: ssType._response.topSongsList,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getTranscodeDecision: {
|
||||||
|
body: ssType._body.getTranscodeDecision,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'getTranscodeDecision.view',
|
||||||
|
query: ssType._parameters.getTranscodeDecision,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.getTranscodeDecision,
|
||||||
|
},
|
||||||
|
},
|
||||||
getUser: {
|
getUser: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'getUser.view',
|
path: 'getUser.view',
|
||||||
@@ -392,7 +401,7 @@ export const ssApiClient = (args: {
|
|||||||
const { server, signal, silent, url } = args;
|
const { server, signal, silent, url } = args;
|
||||||
|
|
||||||
return initClient(contract, {
|
return initClient(contract, {
|
||||||
api: async ({ headers, method, path }) => {
|
api: async ({ body, headers, method, path, rawQuery }) => {
|
||||||
let baseUrl: string | undefined;
|
let baseUrl: string | undefined;
|
||||||
const authParams: Record<string, any> = {};
|
const authParams: Record<string, any> = {};
|
||||||
|
|
||||||
@@ -423,19 +432,44 @@ export const ssApiClient = (args: {
|
|||||||
url: `${baseUrl}/${api}`,
|
url: `${baseUrl}/${api}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = {
|
const isGetTranscodeDecisionPost =
|
||||||
c: 'Feishin',
|
method === 'POST' && api === 'getTranscodeDecision.view';
|
||||||
f: 'json',
|
|
||||||
v: '1.13.0',
|
|
||||||
...authParams,
|
|
||||||
...params,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
|
if (isGetTranscodeDecisionPost && body != null) {
|
||||||
|
request.method = 'POST';
|
||||||
|
request.headers = {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
request.data = body;
|
||||||
|
request.params = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...(typeof rawQuery === 'object' && rawQuery !== null
|
||||||
|
? (rawQuery as Record<string, unknown>)
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
} else if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
|
||||||
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||||
request.method = 'POST';
|
request.method = 'POST';
|
||||||
|
const data = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...params,
|
||||||
|
};
|
||||||
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
|
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
|
||||||
} else {
|
} else {
|
||||||
|
const data = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
...params,
|
||||||
|
};
|
||||||
request.method = method;
|
request.method = method;
|
||||||
request.params = data;
|
request.params = data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1802,9 +1802,22 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
return totalRecordCount;
|
return totalRecordCount;
|
||||||
},
|
},
|
||||||
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
||||||
const { bitrate, format, id, transcode } = query;
|
const {
|
||||||
let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
|
bitrate,
|
||||||
|
format,
|
||||||
|
id,
|
||||||
|
mediaType = 'song',
|
||||||
|
offset = 0,
|
||||||
|
transcode,
|
||||||
|
transcodeParams,
|
||||||
|
} = query;
|
||||||
|
|
||||||
|
if (transcodeParams != null) {
|
||||||
|
const q = `mediaId=${encodeURIComponent(id)}&mediaType=${mediaType}&offset=${offset}&transcodeParams=${transcodeParams}&v=1.13.0&c=Feishin`;
|
||||||
|
return `${server?.url}/rest/getTranscodeStream.view?${q}&${server?.credential}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
|
||||||
if (transcode) {
|
if (transcode) {
|
||||||
if (format) {
|
if (format) {
|
||||||
url += `&format=${format}`;
|
url += `&format=${format}`;
|
||||||
@@ -1813,7 +1826,6 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
url += `&maxBitRate=${bitrate}`;
|
url += `&maxBitRate=${bitrate}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
},
|
},
|
||||||
getStructuredLyrics: async (args) => {
|
getStructuredLyrics: async (args) => {
|
||||||
@@ -1911,6 +1923,32 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: res.totalRecordCount,
|
totalRecordCount: res.totalRecordCount,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
getTranscodeDecision: async (args) => {
|
||||||
|
const { apiClientProps, body, query } = args;
|
||||||
|
|
||||||
|
const defaultBody = {
|
||||||
|
name: 'Feishin',
|
||||||
|
platform: 'Web',
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).getTranscodeDecision({
|
||||||
|
body: body ?? defaultBody,
|
||||||
|
query: {
|
||||||
|
mediaId: query.id,
|
||||||
|
mediaType: query.type === 'song' ? 'song' : 'podcast',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get transcode decision');
|
||||||
|
}
|
||||||
|
|
||||||
|
const td = res.body.transcodeDecision;
|
||||||
|
return {
|
||||||
|
decision: td.canDirectPlay ? 'direct' : 'transcode',
|
||||||
|
transcodeParams: td.transcodeParams,
|
||||||
|
};
|
||||||
|
},
|
||||||
getUserInfo: async (args) => {
|
getUserInfo: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,80 @@ const userParameters = z.object({
|
|||||||
username: z.string(),
|
username: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const transcodeDecisionParameters = z.object({
|
||||||
|
mediaId: z.string(),
|
||||||
|
mediaType: z.enum(['song', 'podcast']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTranscodeStreamParameters = z.object({
|
||||||
|
mediaId: z.string(),
|
||||||
|
mediaType: z.enum(['song', 'podcast']),
|
||||||
|
offset: z.number().optional(),
|
||||||
|
transcodeParams: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const codecProfileLimitation = z.object({
|
||||||
|
comparison: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
values: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const directPlayProfile = z.object({
|
||||||
|
audioCodecs: z.array(z.string()),
|
||||||
|
containers: z.array(z.string()),
|
||||||
|
maxAudioChannels: z.number().optional(),
|
||||||
|
protocols: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcodingProfile = z.object({
|
||||||
|
audioCodec: z.string(),
|
||||||
|
container: z.string(),
|
||||||
|
maxAudioChannels: z.number().optional(),
|
||||||
|
protocol: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const codecProfile = z.object({
|
||||||
|
limitations: z.array(codecProfileLimitation).optional(),
|
||||||
|
name: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcodeDecisionRequestBody = z.object({
|
||||||
|
codecProfiles: z.array(codecProfile).optional(),
|
||||||
|
directPlayProfiles: z.array(directPlayProfile).optional(),
|
||||||
|
maxAudioBitrate: z.number().optional(),
|
||||||
|
maxTranscodingAudioBitrate: z.number().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
platform: z.string(),
|
||||||
|
transcodingProfiles: z.array(transcodingProfile).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamDetails = z.object({
|
||||||
|
audioBitdepth: z.number().optional(),
|
||||||
|
audioBitrate: z.number().optional(),
|
||||||
|
audioChannels: z.number().optional(),
|
||||||
|
audioProfile: z.string().optional(),
|
||||||
|
audioSamplerate: z.number().optional(),
|
||||||
|
codec: z.string().optional(),
|
||||||
|
container: z.string().optional(),
|
||||||
|
protocol: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcodeDecision = z.object({
|
||||||
|
canDirectPlay: z.boolean(),
|
||||||
|
canTranscode: z.boolean(),
|
||||||
|
errorReason: z.string().optional(),
|
||||||
|
sourceStream: streamDetails.optional(),
|
||||||
|
transcodeParams: z.string().optional(),
|
||||||
|
transcodeReason: z.array(z.string()).optional(),
|
||||||
|
transcodeStream: streamDetails.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTranscodeDecision = z.object({
|
||||||
|
transcodeDecision,
|
||||||
|
});
|
||||||
|
|
||||||
const user = z.object({
|
const user = z.object({
|
||||||
user: z.object({
|
user: z.object({
|
||||||
adminRole: z.boolean(),
|
adminRole: z.boolean(),
|
||||||
@@ -718,6 +792,9 @@ const getInternetRadioStations = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const ssType = {
|
export const ssType = {
|
||||||
|
_body: {
|
||||||
|
getTranscodeDecision: transcodeDecisionRequestBody,
|
||||||
|
},
|
||||||
_parameters: {
|
_parameters: {
|
||||||
albumInfo: albumInfoParameters,
|
albumInfo: albumInfoParameters,
|
||||||
albumList: albumListParameters,
|
albumList: albumListParameters,
|
||||||
@@ -741,6 +818,8 @@ export const ssType = {
|
|||||||
getSong: getSongParameters,
|
getSong: getSongParameters,
|
||||||
getSongsByGenre: getSongsByGenreParameters,
|
getSongsByGenre: getSongsByGenreParameters,
|
||||||
getStarred: getStarredParameters,
|
getStarred: getStarredParameters,
|
||||||
|
getTranscodeDecision: transcodeDecisionParameters,
|
||||||
|
getTranscodeStream: getTranscodeStreamParameters,
|
||||||
randomSongList: randomSongListParameters,
|
randomSongList: randomSongListParameters,
|
||||||
removeFavorite: removeFavoriteParameters,
|
removeFavorite: removeFavoriteParameters,
|
||||||
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||||
@@ -786,6 +865,7 @@ export const ssType = {
|
|||||||
getSong,
|
getSong,
|
||||||
getSongsByGenre,
|
getSongsByGenre,
|
||||||
getStarred,
|
getStarred,
|
||||||
|
getTranscodeDecision,
|
||||||
internetRadioStation,
|
internetRadioStation,
|
||||||
musicFolderList,
|
musicFolderList,
|
||||||
ping,
|
ping,
|
||||||
|
|||||||
@@ -1420,7 +1420,7 @@ export type ControllerEndpoint = {
|
|||||||
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||||
getTagList?: (args: TagListArgs) => Promise<TagListResponse>;
|
getTagList?: (args: TagListArgs) => Promise<TagListResponse>;
|
||||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||||
// getArtistInfo?: (args: any) => void;
|
getTranscodeDecision: (args: TranscodeDecisionArgs) => Promise<TranscodeDecisionResponse>;
|
||||||
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
|
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
|
||||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||||
@@ -1569,6 +1569,9 @@ export type InternalControllerEndpoint = {
|
|||||||
) => Promise<StructuredLyric[]>;
|
) => Promise<StructuredLyric[]>;
|
||||||
getTagList?: (args: ReplaceApiClientProps<TagListArgs>) => Promise<TagListResponse>;
|
getTagList?: (args: ReplaceApiClientProps<TagListArgs>) => Promise<TagListResponse>;
|
||||||
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
|
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
|
||||||
|
getTranscodeDecision: (
|
||||||
|
args: ReplaceApiClientProps<TranscodeDecisionArgs>,
|
||||||
|
) => Promise<TranscodeDecisionResponse>;
|
||||||
getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>;
|
getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>;
|
||||||
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
|
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
|
||||||
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
||||||
@@ -1667,7 +1670,10 @@ export type StreamQuery = {
|
|||||||
bitrate?: number;
|
bitrate?: number;
|
||||||
format?: string;
|
format?: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
mediaType?: 'podcast' | 'song';
|
||||||
|
offset?: number;
|
||||||
transcode: boolean;
|
transcode: boolean;
|
||||||
|
transcodeParams?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StructuredLyric = (StructuredSyncedLyric | StructuredUnsyncedLyric) & {
|
export type StructuredLyric = (StructuredSyncedLyric | StructuredUnsyncedLyric) & {
|
||||||
@@ -1711,6 +1717,50 @@ export type TagListResponse = {
|
|||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionArgs = BaseEndpointArgs & {
|
||||||
|
body?: TranscodeDecisionRequestBody;
|
||||||
|
query: TranscodeDecisionQuery;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionQuery = {
|
||||||
|
id: string;
|
||||||
|
type: 'song';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionRequestBody = {
|
||||||
|
codecProfiles?: Array<{
|
||||||
|
limitations?: Array<{
|
||||||
|
comparison: string;
|
||||||
|
name: string;
|
||||||
|
required?: boolean;
|
||||||
|
values: string[];
|
||||||
|
}>;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
directPlayProfiles?: Array<{
|
||||||
|
audioCodecs: string[];
|
||||||
|
containers: string[];
|
||||||
|
maxAudioChannels?: number;
|
||||||
|
protocols: string[];
|
||||||
|
}>;
|
||||||
|
maxAudioBitrate?: number;
|
||||||
|
maxTranscodingAudioBitrate?: number;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
transcodingProfiles?: Array<{
|
||||||
|
audioCodec: string;
|
||||||
|
container: string;
|
||||||
|
maxAudioChannels?: number;
|
||||||
|
protocol: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscodeDecisionResponse = {
|
||||||
|
decision: 'direct' | 'transcode';
|
||||||
|
transcodeParams?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery };
|
export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery };
|
||||||
|
|
||||||
export type UserInfoQuery = {
|
export type UserInfoQuery = {
|
||||||
|
|||||||
Reference in New Issue
Block a user