From 52dea17d148a898aafc6d900617d714ac186e376 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 9 Mar 2026 21:50:03 -0700 Subject: [PATCH] add getTranscodeDecision controller endpoint and types --- src/renderer/api/controller.ts | 14 ++++ .../api/jellyfin/jellyfin-controller.ts | 21 ++--- .../api/navidrome/navidrome-controller.ts | 1 + src/renderer/api/subsonic/subsonic-api.ts | 52 +++++++++--- .../api/subsonic/subsonic-controller.ts | 44 +++++++++- src/shared/api/subsonic/subsonic-types.ts | 80 +++++++++++++++++++ src/shared/types/domain-types.ts | 52 +++++++++++- 7 files changed, 238 insertions(+), 26 deletions(-) diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index a9362248c..97f7b06ad 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -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); diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 6f0f877cd..07fe785cf 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -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; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 4a0e398f6..d8906630d 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -941,6 +941,7 @@ export const NavidromeController: InternalControllerEndpoint = { totalRecordCount: res.totalRecordCount, }; }, + getTranscodeDecision: SubsonicController.getTranscodeDecision, getUserInfo: SubsonicController.getUserInfo, getUserList: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index efa6d9c75..06ed086ff 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -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 = {}; @@ -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) + : {}), + }; + } 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; } diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 73639fef0..3a8b60bf3 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -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; diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index 777d2db9b..dc1936c9a 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -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, diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 572f7bf26..69fc46e9f 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1420,7 +1420,7 @@ export type ControllerEndpoint = { getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise; getTagList?: (args: TagListArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; - // getArtistInfo?: (args: any) => void; + getTranscodeDecision: (args: TranscodeDecisionArgs) => Promise; getUserInfo: (args: UserInfoArgs) => Promise; getUserList?: (args: UserListArgs) => Promise; movePlaylistItem?: (args: MoveItemArgs) => Promise; @@ -1569,6 +1569,9 @@ export type InternalControllerEndpoint = { ) => Promise; getTagList?: (args: ReplaceApiClientProps) => Promise; getTopSongs: (args: ReplaceApiClientProps) => Promise; + getTranscodeDecision: ( + args: ReplaceApiClientProps, + ) => Promise; getUserInfo: (args: ReplaceApiClientProps) => Promise; getUserList?: (args: ReplaceApiClientProps) => Promise; movePlaylistItem?: (args: ReplaceApiClientProps) => Promise; @@ -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 = {