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,
)?.(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;
+43 -9
View File
@@ -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;
+80
View File
@@ -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,
+51 -1
View File
@@ -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 = {