From a30b1ec90b15f21a460e4bc85ed3d95b44b2adeb Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 9 Mar 2026 21:50:03 -0700 Subject: [PATCH] add OS transcoding extension --- src/renderer/api/controller.ts | 5 +- .../api/jellyfin/jellyfin-controller.ts | 2 +- src/renderer/api/subsonic/subsonic-api.ts | 60 ++++- .../api/subsonic/subsonic-controller.ts | 235 +++++++++++++++++- .../audio-player/engine/mpv-player-engine.tsx | 18 +- .../audio-player/hooks/use-stream-url.tsx | 80 +++--- .../player/components/audio-players.tsx | 51 ++++ src/shared/api/subsonic/subsonic-types.ts | 81 ++++++ src/shared/types/domain-types.ts | 67 ++++- src/shared/types/features-types.ts | 1 + 10 files changed, 529 insertions(+), 71 deletions(-) diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index a9362248c..1debe99ed 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -69,6 +69,7 @@ const getPathReplaceSettings = () => { const addContext = (args: T): T => { const pathSettings = getPathReplaceSettings(); + return { ...args, context: { @@ -719,7 +720,9 @@ export const controller: GeneralController = { const server = getServerById(args.apiClientProps.serverId); if (!server) { - return ''; + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStreamUrl`, + ); } return apiController( diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 6f0f877cd..6460b6787 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -1283,7 +1283,7 @@ export const JellyfinController: InternalControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), - getStreamUrl: ({ apiClientProps: { server }, query }) => { + getStreamUrl: async ({ apiClientProps: { server }, query }) => { const { bitrate, format, id, transcode } = query; const deviceId = ''; diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index efa6d9c75..e82be0c3e 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -250,6 +250,23 @@ 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, + }, + }, + getTranscodeStream: { + method: 'GET', + path: 'getTranscodeStream.view', + query: ssType._parameters.getTranscodeStream, + responses: { + 200: z.string(), + }, + }, getUser: { method: 'GET', path: 'getUser.view', @@ -392,7 +409,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 +440,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..901f948f3 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -8,7 +8,12 @@ import md5 from 'md5'; import { z } from 'zod'; import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; +import { + getDefaultTranscodingProfiles, + getDirectPlayProfiles, +} from '/@/renderer/features/player/components/audio-players'; import { randomString } from '/@/renderer/utils'; +import { logFn } from '/@/renderer/utils/logger'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize'; import { @@ -87,6 +92,151 @@ const ALBUM_LIST_SORT_MAPPING: Record( items: T[], options: { @@ -1273,6 +1423,10 @@ export const SubsonicController: InternalControllerEndpoint = { } } + if (subsonicFeatures[SubsonicExtensions.TRANSCODING]) { + features.osTranscodeDecision = [1]; + } + if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) { features.lyricsMultipleStructured = [1]; } @@ -1801,20 +1955,81 @@ 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}`; + getStreamUrl: async ({ apiClientProps, query }) => { + const { server } = apiClientProps; + const { bitrate, format, id, mediaType = 'song', skipAutoTranscode, transcode } = query; + const streamUrl = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`; + + // If transcoding is explicitly enabled, just return the direct transcoded stream URL if (transcode) { - if (format) { - url += `&format=${format}`; - } - if (bitrate !== undefined) { - url += `&maxBitRate=${bitrate}`; - } + return appendTranscodeParams(streamUrl, format, bitrate); } - return url; + // Used in cases where MPV is the default player, since mpv handles basically every audio format + if (skipAutoTranscode) { + return streamUrl; + } + + // If the server supports transcoding decision, always use it to determine if we need to transcode + if (hasFeature(server, ServerFeature.OS_TRANSCODE_DECISION)) { + const maxTranscodingAudioBitrate = 0; + + const directPlayProfiles = getDirectPlayProfiles(); + const transcodingProfiles = getDefaultTranscodingProfiles(); + + const transcodeDecision = await ssApiClient(apiClientProps).getTranscodeDecision({ + body: { + codecProfiles: [], + directPlayProfiles, + maxAudioBitrate: 0, + maxTranscodingAudioBitrate, + name: 'Feishin', + platform: navigator.userAgent, + transcodingProfiles, + }, + query: { + mediaId: id, + mediaType, + }, + }); + + if (transcodeDecision.status !== 200) { + throw new Error('Failed to get transcode decision'); + } + + const td = transcodeDecision.body.transcodeDecision; + const requiresTranscoding = !td?.canDirectPlay; + + // If the server does not require transcoding, just return the direct stream URL + if (!requiresTranscoding) { + return streamUrl; + } + + logFn.info(`Song ${id} requires transcoding: ${[td.transcodeReason].join(', ')}`); + + // If the server does not return transcode params, manually create the transcode params + if (!td.transcodeParams) { + return appendTranscodeParams(streamUrl, format, bitrate); + } + + const transcodeStreamUrl = await ssApiClient(apiClientProps).getTranscodeStream({ + query: { + mediaId: id, + mediaType, + offset: 0, + transcodeParams: td.transcodeParams, + }, + }); + + if (transcodeStreamUrl.status !== 200) { + throw new Error('Failed to get transcode stream'); + } + + return transcodeStreamUrl.body; + } + + return streamUrl; }, getStructuredLyrics: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx index 7e5861b7a..db370c89c 100644 --- a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx +++ b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx @@ -124,10 +124,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { if (!radioState.currentStreamUrl) { const playerData = usePlayerStore.getState().getPlayerData(); const currentSongUrl = playerData.currentSong - ? getSongUrl(playerData.currentSong, transcode) + ? await getSongUrl(playerData.currentSong, transcode, true) : undefined; const nextSongUrl = playerData.nextSong - ? getSongUrl(playerData.nextSong, transcode) + ? await getSongUrl(playerData.nextSong, transcode, true) : undefined; if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) { @@ -274,14 +274,14 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { onMediaPrev: () => { replaceMpvQueue(transcode); }, - onNextSongInsertion: (song) => { + onNextSongInsertion: async (song) => { const radioState = useRadioStore.getState(); if (radioState.currentStreamUrl) { return; } - const nextSongUrl = song ? getSongUrl(song, transcode) : undefined; + const nextSongUrl = song ? await getSongUrl(song, transcode, true) : undefined; mpvPlayer?.setQueueNext(nextSongUrl); }, onPlayerPlay: () => { @@ -339,19 +339,19 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { MpvPlayerEngine.displayName = 'MpvPlayerEngine'; -function handleMpvAutoNext(transcode: { +async function handleMpvAutoNext(transcode: { bitrate?: number | undefined; enabled: boolean; format?: string | undefined; }) { const playerData = usePlayerStore.getState().getPlayerData(); const nextSongUrl = playerData.nextSong - ? getSongUrl(playerData.nextSong, transcode) + ? await getSongUrl(playerData.nextSong, transcode, true) : undefined; mpvPlayer?.autoNext(nextSongUrl); } -function replaceMpvQueue(transcode: { +async function replaceMpvQueue(transcode: { bitrate?: number | undefined; enabled: boolean; format?: string | undefined; @@ -365,10 +365,10 @@ function replaceMpvQueue(transcode: { const playerData = usePlayerStore.getState().getPlayerData(); const currentSongUrl = playerData.currentSong - ? getSongUrl(playerData.currentSong, transcode) + ? await getSongUrl(playerData.currentSong, transcode, true) : undefined; const nextSongUrl = playerData.nextSong - ? getSongUrl(playerData.nextSong, transcode) + ? await getSongUrl(playerData.nextSong, transcode, true) : undefined; mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false); } diff --git a/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx b/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx index c0462a95b..2291e16aa 100644 --- a/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx +++ b/src/renderer/features/player/audio-player/hooks/use-stream-url.tsx @@ -1,4 +1,5 @@ -import { useMemo, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; import { api } from '/@/renderer/api'; import { TranscodingConfig } from '/@/renderer/store'; @@ -10,52 +11,71 @@ export function useSongUrl( transcode: TranscodingConfig, ): string | undefined { const prior = useRef(['', '']); + const shouldReusePrior = Boolean( + song?._serverId && current && prior.current[0] === song._uniqueId && prior.current[1], + ); - return useMemo(() => { - if (song?._serverId) { - // If we are the current track, we do not want a transcoding - // reconfiguration to force a restart. - if (current && prior.current[0] === song._uniqueId) { - return prior.current[1]; - } - - const url = api.controller.getStreamUrl({ - apiClientProps: { serverId: song._serverId }, + const { data: queryStreamUrl } = useQuery({ + enabled: Boolean(song?._serverId) && !shouldReusePrior, + queryFn: () => + api.controller.getStreamUrl({ + apiClientProps: { serverId: song!._serverId }, query: { bitrate: transcode.bitrate, format: transcode.format, - id: song.id, + id: song!.id, transcode: transcode.enabled, }, - }); + }), + queryKey: [ + song?._serverId, + 'stream-url', + song?.id, + shouldReusePrior ? 'reuse-prior' : transcode.bitrate, + shouldReusePrior ? 'reuse-prior' : transcode.format, + shouldReusePrior ? 'reuse-prior' : transcode.enabled, + ] as const, + staleTime: 60 * 1000, + }); - // transcoding enabled; save the updated result - prior.current = [song._uniqueId, url]; - return url; + useEffect(() => { + if (!song?._serverId) { + prior.current = ['', '']; + return; } - // no track; clear result - prior.current = ['', '']; - return undefined; - }, [ - song?._serverId, - song?._uniqueId, - song?.id, - current, - transcode.bitrate, - transcode.format, - transcode.enabled, - ]); + if (!queryStreamUrl) { + return; + } + + // Save resolved URL to avoid restarting current track on transcode setting changes. + prior.current = [song._uniqueId, queryStreamUrl]; + }, [song?._serverId, song?._uniqueId, queryStreamUrl]); + + useEffect(() => { + if (!song?._serverId) { + prior.current = ['', '']; + } + }, [song?._serverId]); + + return shouldReusePrior ? prior.current[1] : queryStreamUrl; } -export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => { - return api.controller.getStreamUrl({ +export const getSongUrl = async ( + song: QueueSong, + transcode: TranscodingConfig, + skipAutoTranscode?: boolean, +) => { + const url = await api.controller.getStreamUrl({ apiClientProps: { serverId: song._serverId }, query: { bitrate: transcode.bitrate, format: transcode.format, id: song.id, + skipAutoTranscode, transcode: transcode.enabled, }, }); + + return url; }; diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index d4d667a50..fb30fbd7b 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -37,6 +37,52 @@ import { toast } from '/@/shared/components/toast/toast'; import { LibraryItem } from '/@/shared/types/domain-types'; import { PlayerType } from '/@/shared/types/types'; +const CODEC_PROBES = [ + { codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' }, + { codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' }, + { codec: 'opus', container: 'ogg', mime: 'audio/ogg; codecs="opus"' }, + { codec: 'vorbis', container: 'ogg', mime: 'audio/ogg; codecs="vorbis"' }, + { codec: 'flac', container: 'flac', mime: 'audio/flac' }, + { codec: 'wav', container: 'wav', mime: 'audio/wav' }, + { codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' }, +]; + +const DEFAULT_TRANSCODING_PROFILES = [ + { audioCodec: 'opus', container: 'ogg', protocol: 'http' }, + { audioCodec: 'mp3', container: 'mp3', protocol: 'http' }, +]; + +const DIRECT_PLAY_PROFILES: { + audioCodecs: string[]; + containers: string[]; + protocols: string[]; +}[] = []; + +export function getDefaultTranscodingProfiles() { + return DEFAULT_TRANSCODING_PROFILES; +} + +export function getDirectPlayProfiles() { + return DIRECT_PLAY_PROFILES; +} + +// Shamelessly taken from NavidromeUI +function detectBrowserProfile() { + const audio = new Audio(); + + for (const { codec, container, mime } of CODEC_PROBES) { + if (audio.canPlayType(mime) === 'probably') { + DIRECT_PLAY_PROFILES.push({ + audioCodecs: [codec], + containers: [container], + protocols: ['http'], + }); + } + } + + return DIRECT_PLAY_PROFILES; +} + export const AudioPlayers = () => { const playbackType = usePlaybackType(); const serverId = useCurrentServerId(); @@ -49,6 +95,11 @@ export const AudioPlayers = () => { } = usePlaybackSettings(); const { setWebAudio, webAudio: audioContext } = useWebAudio(); + useEffect(() => { + console.log('getDirectPlayProfiles'); + detectBrowserProfile(); + }, []); + return ( <> diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index 777d2db9b..f591d49b8 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(), @@ -382,6 +456,7 @@ export enum SubsonicExtensions { INDEX_BASED_QUEUE = 'indexBasedQueue', SONG_LYRICS = 'songLyrics', TRANSCODE_OFFSET = 'transcodeOffset', + TRANSCODING = 'transcoding', } const updatePlaylistParameters = z.object({ @@ -718,6 +793,9 @@ const getInternetRadioStations = z.object({ }); export const ssType = { + _body: { + getTranscodeDecision: transcodeDecisionRequestBody, + }, _parameters: { albumInfo: albumInfoParameters, albumList: albumListParameters, @@ -741,6 +819,8 @@ export const ssType = { getSong: getSongParameters, getSongsByGenre: getSongsByGenreParameters, getStarred: getStarredParameters, + getTranscodeDecision: transcodeDecisionParameters, + getTranscodeStream: getTranscodeStreamParameters, randomSongList: randomSongListParameters, removeFavorite: removeFavoriteParameters, savePlayQueueByIndex: savePlayQueueByIndexParameters, @@ -786,6 +866,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..77459a7fc 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -410,16 +410,18 @@ export type Song = { userRating: null | number; }; +type ApiContext = { + pathReplace?: string; + pathReplaceWith?: string; +}; + type BaseEndpointArgs = { apiClientProps: { server?: null | ServerListItemWithCredential; serverId: string; signal?: AbortSignal; }; - context?: { - pathReplace?: string; - pathReplaceWith?: string; - }; + context?: ApiContext; }; type GenreListSortMap = { @@ -1416,11 +1418,10 @@ export type ControllerEndpoint = { getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; getSongListCount: (args: SongListCountArgs) => Promise; - getStreamUrl: (args: StreamArgs) => string; + getStreamUrl: (args: StreamArgs) => Promise; getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise; getTagList?: (args: TagListArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; - // getArtistInfo?: (args: any) => void; getUserInfo: (args: UserInfoArgs) => Promise; getUserList?: (args: UserListArgs) => Promise; movePlaylistItem?: (args: MoveItemArgs) => Promise; @@ -1563,7 +1564,7 @@ export type InternalControllerEndpoint = { getSongDetail: (args: ReplaceApiClientProps) => Promise; getSongList: (args: ReplaceApiClientProps) => Promise; getSongListCount: (args: ReplaceApiClientProps) => Promise; - getStreamUrl: (args: ReplaceApiClientProps) => string; + getStreamUrl: (args: ReplaceApiClientProps) => Promise; getStructuredLyrics?: ( args: ReplaceApiClientProps, ) => Promise; @@ -1667,6 +1668,9 @@ export type StreamQuery = { bitrate?: number; format?: string; id: string; + mediaType?: 'podcast' | 'song'; + offset?: number; + skipAutoTranscode?: boolean; transcode: boolean; }; @@ -1711,6 +1715,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 = { @@ -1730,8 +1778,5 @@ type BaseEndpointArgsWithServer = { serverId: string; signal?: AbortSignal; }; - context?: { - pathReplace?: string; - pathReplaceWith?: string; - }; + context?: ApiContext; }; diff --git a/src/shared/types/features-types.ts b/src/shared/types/features-types.ts index 33a722a53..00218d266 100644 --- a/src/shared/types/features-types.ts +++ b/src/shared/types/features-types.ts @@ -7,6 +7,7 @@ export enum ServerFeature { LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect', OS_FORM_POST = 'osFormPost', + OS_TRANSCODE_DECISION = 'osTranscodeDecision', PLAYLISTS_SMART = 'playlistsSmart', PUBLIC_PLAYLIST = 'publicPlaylist', SERVER_PLAY_QUEUE = 'serverPlayQueue',