mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
add getTranscodeDecision controller endpoint and types
This commit is contained in:
@@ -769,6 +769,20 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.(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) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
|
||||
@@ -1284,39 +1284,34 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
query: { ...query, limit: 1, startIndex: 0 },
|
||||
}).then((result) => result!.totalRecordCount!),
|
||||
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
||||
const { bitrate, format, id, transcode } = query;
|
||||
const { bitrate, format, id, offset = 0, transcode, transcodeParams } = query;
|
||||
const deviceId = '';
|
||||
|
||||
let url = `${server?.url}/Items/${id}/Download?apiKey=${server?.credential}&playSessionId=${deviceId}`;
|
||||
|
||||
if (transcode) {
|
||||
// Some format appears to be required. Fall back to trusty MP3 if not specified
|
||||
// Otherwise, ffmpeg appears to crash
|
||||
if (transcodeParams != null || transcode) {
|
||||
const realFormat = format || 'mp3';
|
||||
|
||||
url =
|
||||
let url =
|
||||
`${server?.url}/audio` +
|
||||
`/${id}/universal` +
|
||||
`?userId=${server?.userId}` +
|
||||
`&deviceId=${deviceId}` +
|
||||
'&audioCodec=aac' +
|
||||
`&apiKey=${server?.credential}` +
|
||||
`&playSessionId=${deviceId}` +
|
||||
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg';
|
||||
|
||||
url += `&transcodingProtocol=http&transcodingContainer=${realFormat}`;
|
||||
url = url.replace('audioCodec=aac', `audioCodec=${realFormat}`);
|
||||
url = url.replace(
|
||||
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg',
|
||||
`&container=${realFormat}`,
|
||||
);
|
||||
|
||||
if (bitrate !== undefined) {
|
||||
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) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -941,6 +941,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
totalRecordCount: res.totalRecordCount,
|
||||
};
|
||||
},
|
||||
getTranscodeDecision: SubsonicController.getTranscodeDecision,
|
||||
getUserInfo: SubsonicController.getUserInfo,
|
||||
getUserList: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -250,6 +250,15 @@ export const contract = c.router({
|
||||
200: ssType._response.topSongsList,
|
||||
},
|
||||
},
|
||||
getTranscodeDecision: {
|
||||
body: ssType._body.getTranscodeDecision,
|
||||
method: 'POST',
|
||||
path: 'getTranscodeDecision.view',
|
||||
query: ssType._parameters.getTranscodeDecision,
|
||||
responses: {
|
||||
200: ssType._response.getTranscodeDecision,
|
||||
},
|
||||
},
|
||||
getUser: {
|
||||
method: 'GET',
|
||||
path: 'getUser.view',
|
||||
@@ -392,7 +401,7 @@ export const ssApiClient = (args: {
|
||||
const { server, signal, silent, url } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ headers, method, path }) => {
|
||||
api: async ({ body, headers, method, path, rawQuery }) => {
|
||||
let baseUrl: string | undefined;
|
||||
const authParams: Record<string, any> = {};
|
||||
|
||||
@@ -423,19 +432,44 @@ export const ssApiClient = (args: {
|
||||
url: `${baseUrl}/${api}`,
|
||||
};
|
||||
|
||||
const data = {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
v: '1.13.0',
|
||||
...authParams,
|
||||
...params,
|
||||
};
|
||||
const isGetTranscodeDecisionPost =
|
||||
method === 'POST' && api === 'getTranscodeDecision.view';
|
||||
|
||||
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';
|
||||
request.method = 'POST';
|
||||
const data = {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
v: '1.13.0',
|
||||
...authParams,
|
||||
...params,
|
||||
};
|
||||
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
|
||||
} else {
|
||||
const data = {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
v: '1.13.0',
|
||||
...authParams,
|
||||
...params,
|
||||
};
|
||||
request.method = method;
|
||||
request.params = data;
|
||||
}
|
||||
|
||||
@@ -1802,9 +1802,22 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return totalRecordCount;
|
||||
},
|
||||
getStreamUrl: ({ apiClientProps: { server }, query }) => {
|
||||
const { bitrate, format, id, transcode } = query;
|
||||
let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
|
||||
const {
|
||||
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 (format) {
|
||||
url += `&format=${format}`;
|
||||
@@ -1813,7 +1826,6 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
url += `&maxBitRate=${bitrate}`;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
},
|
||||
getStructuredLyrics: async (args) => {
|
||||
@@ -1911,6 +1923,32 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
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) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -11,6 +11,80 @@ const userParameters = z.object({
|
||||
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({
|
||||
user: z.object({
|
||||
adminRole: z.boolean(),
|
||||
@@ -718,6 +792,9 @@ const getInternetRadioStations = z.object({
|
||||
});
|
||||
|
||||
export const ssType = {
|
||||
_body: {
|
||||
getTranscodeDecision: transcodeDecisionRequestBody,
|
||||
},
|
||||
_parameters: {
|
||||
albumInfo: albumInfoParameters,
|
||||
albumList: albumListParameters,
|
||||
@@ -741,6 +818,8 @@ export const ssType = {
|
||||
getSong: getSongParameters,
|
||||
getSongsByGenre: getSongsByGenreParameters,
|
||||
getStarred: getStarredParameters,
|
||||
getTranscodeDecision: transcodeDecisionParameters,
|
||||
getTranscodeStream: getTranscodeStreamParameters,
|
||||
randomSongList: randomSongListParameters,
|
||||
removeFavorite: removeFavoriteParameters,
|
||||
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||
@@ -786,6 +865,7 @@ export const ssType = {
|
||||
getSong,
|
||||
getSongsByGenre,
|
||||
getStarred,
|
||||
getTranscodeDecision,
|
||||
internetRadioStation,
|
||||
musicFolderList,
|
||||
ping,
|
||||
|
||||
@@ -1420,7 +1420,7 @@ export type ControllerEndpoint = {
|
||||
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||
getTagList?: (args: TagListArgs) => Promise<TagListResponse>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
// getArtistInfo?: (args: any) => void;
|
||||
getTranscodeDecision: (args: TranscodeDecisionArgs) => Promise<TranscodeDecisionResponse>;
|
||||
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
|
||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||
@@ -1569,6 +1569,9 @@ export type InternalControllerEndpoint = {
|
||||
) => Promise<StructuredLyric[]>;
|
||||
getTagList?: (args: ReplaceApiClientProps<TagListArgs>) => Promise<TagListResponse>;
|
||||
getTopSongs: (args: ReplaceApiClientProps<TopSongListArgs>) => Promise<TopSongListResponse>;
|
||||
getTranscodeDecision: (
|
||||
args: ReplaceApiClientProps<TranscodeDecisionArgs>,
|
||||
) => Promise<TranscodeDecisionResponse>;
|
||||
getUserInfo: (args: ReplaceApiClientProps<UserInfoArgs>) => Promise<UserInfoResponse>;
|
||||
getUserList?: (args: ReplaceApiClientProps<UserListArgs>) => Promise<UserListResponse>;
|
||||
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
||||
@@ -1667,7 +1670,10 @@ export type StreamQuery = {
|
||||
bitrate?: number;
|
||||
format?: string;
|
||||
id: string;
|
||||
mediaType?: 'podcast' | 'song';
|
||||
offset?: number;
|
||||
transcode: boolean;
|
||||
transcodeParams?: string;
|
||||
};
|
||||
|
||||
export type StructuredLyric = (StructuredSyncedLyric | StructuredUnsyncedLyric) & {
|
||||
@@ -1711,6 +1717,50 @@ export type TagListResponse = {
|
||||
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 UserInfoQuery = {
|
||||
|
||||
Reference in New Issue
Block a user