add getTranscodeDecision controller endpoint and types

This commit is contained in:
jeffvli
2026-03-09 21:50:03 -07:00
parent baf4e7bc0b
commit 52dea17d14
7 changed files with 238 additions and 26 deletions
+14
View File
@@ -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;
+43 -9
View File
@@ -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;
+80
View File
@@ -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,
+51 -1
View File
@@ -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 = {