add OS transcoding extension

This commit is contained in:
jeffvli
2026-03-09 21:50:03 -07:00
parent 7982c0e1bd
commit a30b1ec90b
10 changed files with 529 additions and 71 deletions
+4 -1
View File
@@ -69,6 +69,7 @@ const getPathReplaceSettings = () => {
const addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => { const addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => {
const pathSettings = getPathReplaceSettings(); const pathSettings = getPathReplaceSettings();
return { return {
...args, ...args,
context: { context: {
@@ -719,7 +720,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
if (!server) { if (!server) {
return ''; throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStreamUrl`,
);
} }
return apiController( return apiController(
@@ -1283,7 +1283,7 @@ export const JellyfinController: InternalControllerEndpoint = {
apiClientProps, apiClientProps,
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: async ({ apiClientProps: { server }, query }) => {
const { bitrate, format, id, transcode } = query; const { bitrate, format, id, transcode } = query;
const deviceId = ''; const deviceId = '';
+47 -5
View File
@@ -250,6 +250,23 @@ 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,
},
},
getTranscodeStream: {
method: 'GET',
path: 'getTranscodeStream.view',
query: ssType._parameters.getTranscodeStream,
responses: {
200: z.string(),
},
},
getUser: { getUser: {
method: 'GET', method: 'GET',
path: 'getUser.view', path: 'getUser.view',
@@ -392,7 +409,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,6 +440,28 @@ export const ssApiClient = (args: {
url: `${baseUrl}/${api}`, url: `${baseUrl}/${api}`,
}; };
const isGetTranscodeDecisionPost =
method === 'POST' && api === 'getTranscodeDecision.view';
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 = { const data = {
c: 'Feishin', c: 'Feishin',
f: 'json', f: 'json',
@@ -430,12 +469,15 @@ export const ssApiClient = (args: {
...authParams, ...authParams,
...params, ...params,
}; };
if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
request.method = 'POST';
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;
} }
+225 -10
View File
@@ -8,7 +8,12 @@ import md5 from 'md5';
import { z } from 'zod'; import { z } from 'zod';
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; 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 { randomString } from '/@/renderer/utils';
import { logFn } from '/@/renderer/utils/logger';
import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize'; import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import { import {
@@ -87,6 +92,151 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
const MAX_SUBSONIC_ITEMS = 500; const MAX_SUBSONIC_ITEMS = 500;
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10; const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
// const TRANSCODE_DIRECT_PLAY_PROFILES = [
// {
// audioCodecs: ['mp3'],
// containers: ['mp3'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['aac'],
// containers: ['m4a', 'mp4'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['vorbis'],
// containers: ['ogg'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['opus'],
// containers: ['ogg', 'webm'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['pcm'],
// containers: ['wav'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['flac'],
// containers: ['flac'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// ];
// const TRANSCODE_UNSUPPORTED_DIRECT_PLAY_PROFILES = [
// {
// containers: ["m4a", "mp4"],
// audioCodecs: ["alac"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["m4a", "mp4"],
// audioCodecs: ["ac3", "eac3"],
// protocols: ["http"],
// maxAudioChannels: 6
// },
// {
// containers: ["ogg"],
// audioCodecs: ["flac", "speex"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["wav"],
// audioCodecs: ["adpcm", "gsm", "aac", "mp3"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["mkv"],
// audioCodecs: ["aac", "mp3", "flac", "opus", "vorbis", "ac3", "eac3", "dts"],
// protocols: ["http"],
// maxAudioChannels: 8
// },
// {
// containers: ["avi"],
// audioCodecs: ["mp3", "ac3", "pcm", "aac"],
// protocols: ["http"],
// maxAudioChannels: 6
// },
// {
// containers: ["asf", "wma"],
// audioCodecs: ["wma", "pcm", "mp3"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["caf"],
// audioCodecs: ["pcm", "aac", "alac", "mp3"],
// protocols: ["http"],
// maxAudioChannels: 8
// },
// {
// containers: ["3gp"],
// audioCodecs: ["aac", "amr"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["amr"],
// audioCodecs: ["amr"],
// protocols: ["http"],
// maxAudioChannels: 1
// },
// {
// containers: ["ape"],
// audioCodecs: ["ape"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["wv"],
// audioCodecs: ["wavpack"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["ac3"],
// audioCodecs: ["ac3"],
// protocols: ["http"],
// maxAudioChannels: 6
// },
// {
// containers: ["eac3"],
// audioCodecs: ["eac3"],
// protocols: ["http"],
// maxAudioChannels: 8
// },
// {
// containers: ["dts"],
// audioCodecs: ["dts"],
// protocols: ["http"],
// maxAudioChannels: 8
// }
// ];
function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
let streamUrl = url;
if (format) {
streamUrl += `&format=${format}`;
}
if (bitrate !== undefined) {
streamUrl += `&maxBitRate=${bitrate}`;
}
return streamUrl;
}
function sortAndPaginate<T>( function sortAndPaginate<T>(
items: T[], items: T[],
options: { options: {
@@ -1273,6 +1423,10 @@ export const SubsonicController: InternalControllerEndpoint = {
} }
} }
if (subsonicFeatures[SubsonicExtensions.TRANSCODING]) {
features.osTranscodeDecision = [1];
}
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) { if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
features.lyricsMultipleStructured = [1]; features.lyricsMultipleStructured = [1];
} }
@@ -1801,20 +1955,81 @@ export const SubsonicController: InternalControllerEndpoint = {
return totalRecordCount; return totalRecordCount;
}, },
getStreamUrl: ({ apiClientProps: { server }, query }) => { getStreamUrl: async ({ apiClientProps, query }) => {
const { bitrate, format, id, transcode } = query; const { server } = apiClientProps;
let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`; 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 (transcode) {
if (format) { return appendTranscodeParams(streamUrl, format, bitrate);
url += `&format=${format}`;
}
if (bitrate !== undefined) {
url += `&maxBitRate=${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) => { getStructuredLyrics: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -124,10 +124,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
if (!radioState.currentStreamUrl) { if (!radioState.currentStreamUrl) {
const playerData = usePlayerStore.getState().getPlayerData(); const playerData = usePlayerStore.getState().getPlayerData();
const currentSongUrl = playerData.currentSong const currentSongUrl = playerData.currentSong
? getSongUrl(playerData.currentSong, transcode) ? await getSongUrl(playerData.currentSong, transcode, true)
: undefined; : undefined;
const nextSongUrl = playerData.nextSong const nextSongUrl = playerData.nextSong
? getSongUrl(playerData.nextSong, transcode) ? await getSongUrl(playerData.nextSong, transcode, true)
: undefined; : undefined;
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) { if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
@@ -274,14 +274,14 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
onMediaPrev: () => { onMediaPrev: () => {
replaceMpvQueue(transcode); replaceMpvQueue(transcode);
}, },
onNextSongInsertion: (song) => { onNextSongInsertion: async (song) => {
const radioState = useRadioStore.getState(); const radioState = useRadioStore.getState();
if (radioState.currentStreamUrl) { if (radioState.currentStreamUrl) {
return; return;
} }
const nextSongUrl = song ? getSongUrl(song, transcode) : undefined; const nextSongUrl = song ? await getSongUrl(song, transcode, true) : undefined;
mpvPlayer?.setQueueNext(nextSongUrl); mpvPlayer?.setQueueNext(nextSongUrl);
}, },
onPlayerPlay: () => { onPlayerPlay: () => {
@@ -339,19 +339,19 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
MpvPlayerEngine.displayName = 'MpvPlayerEngine'; MpvPlayerEngine.displayName = 'MpvPlayerEngine';
function handleMpvAutoNext(transcode: { async function handleMpvAutoNext(transcode: {
bitrate?: number | undefined; bitrate?: number | undefined;
enabled: boolean; enabled: boolean;
format?: string | undefined; format?: string | undefined;
}) { }) {
const playerData = usePlayerStore.getState().getPlayerData(); const playerData = usePlayerStore.getState().getPlayerData();
const nextSongUrl = playerData.nextSong const nextSongUrl = playerData.nextSong
? getSongUrl(playerData.nextSong, transcode) ? await getSongUrl(playerData.nextSong, transcode, true)
: undefined; : undefined;
mpvPlayer?.autoNext(nextSongUrl); mpvPlayer?.autoNext(nextSongUrl);
} }
function replaceMpvQueue(transcode: { async function replaceMpvQueue(transcode: {
bitrate?: number | undefined; bitrate?: number | undefined;
enabled: boolean; enabled: boolean;
format?: string | undefined; format?: string | undefined;
@@ -365,10 +365,10 @@ function replaceMpvQueue(transcode: {
const playerData = usePlayerStore.getState().getPlayerData(); const playerData = usePlayerStore.getState().getPlayerData();
const currentSongUrl = playerData.currentSong const currentSongUrl = playerData.currentSong
? getSongUrl(playerData.currentSong, transcode) ? await getSongUrl(playerData.currentSong, transcode, true)
: undefined; : undefined;
const nextSongUrl = playerData.nextSong const nextSongUrl = playerData.nextSong
? getSongUrl(playerData.nextSong, transcode) ? await getSongUrl(playerData.nextSong, transcode, true)
: undefined; : undefined;
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false); mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
} }
@@ -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 { api } from '/@/renderer/api';
import { TranscodingConfig } from '/@/renderer/store'; import { TranscodingConfig } from '/@/renderer/store';
@@ -10,52 +11,71 @@ export function useSongUrl(
transcode: TranscodingConfig, transcode: TranscodingConfig,
): string | undefined { ): string | undefined {
const prior = useRef(['', '']); const prior = useRef(['', '']);
const shouldReusePrior = Boolean(
song?._serverId && current && prior.current[0] === song._uniqueId && prior.current[1],
);
return useMemo(() => { const { data: queryStreamUrl } = useQuery({
if (song?._serverId) { enabled: Boolean(song?._serverId) && !shouldReusePrior,
// If we are the current track, we do not want a transcoding queryFn: () =>
// reconfiguration to force a restart. api.controller.getStreamUrl({
if (current && prior.current[0] === song._uniqueId) { apiClientProps: { serverId: song!._serverId },
return prior.current[1];
}
const url = api.controller.getStreamUrl({
apiClientProps: { serverId: song._serverId },
query: { query: {
bitrate: transcode.bitrate, bitrate: transcode.bitrate,
format: transcode.format, format: transcode.format,
id: song.id, id: song!.id,
transcode: transcode.enabled, 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 useEffect(() => {
prior.current = [song._uniqueId, url]; if (!song?._serverId) {
return url; prior.current = ['', ''];
return;
} }
// no track; clear result 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 = ['', '']; prior.current = ['', ''];
return undefined; }
}, [ }, [song?._serverId]);
song?._serverId,
song?._uniqueId, return shouldReusePrior ? prior.current[1] : queryStreamUrl;
song?.id,
current,
transcode.bitrate,
transcode.format,
transcode.enabled,
]);
} }
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => { export const getSongUrl = async (
return api.controller.getStreamUrl({ song: QueueSong,
transcode: TranscodingConfig,
skipAutoTranscode?: boolean,
) => {
const url = await api.controller.getStreamUrl({
apiClientProps: { serverId: song._serverId }, apiClientProps: { serverId: song._serverId },
query: { query: {
bitrate: transcode.bitrate, bitrate: transcode.bitrate,
format: transcode.format, format: transcode.format,
id: song.id, id: song.id,
skipAutoTranscode,
transcode: transcode.enabled, transcode: transcode.enabled,
}, },
}); });
return url;
}; };
@@ -37,6 +37,52 @@ import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
import { PlayerType } from '/@/shared/types/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 = () => { export const AudioPlayers = () => {
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
@@ -49,6 +95,11 @@ export const AudioPlayers = () => {
} = usePlaybackSettings(); } = usePlaybackSettings();
const { setWebAudio, webAudio: audioContext } = useWebAudio(); const { setWebAudio, webAudio: audioContext } = useWebAudio();
useEffect(() => {
console.log('getDirectPlayProfiles');
detectBrowserProfile();
}, []);
return ( return (
<> <>
<SleepTimerHook /> <SleepTimerHook />
+81
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(),
@@ -382,6 +456,7 @@ export enum SubsonicExtensions {
INDEX_BASED_QUEUE = 'indexBasedQueue', INDEX_BASED_QUEUE = 'indexBasedQueue',
SONG_LYRICS = 'songLyrics', SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset', TRANSCODE_OFFSET = 'transcodeOffset',
TRANSCODING = 'transcoding',
} }
const updatePlaylistParameters = z.object({ const updatePlaylistParameters = z.object({
@@ -718,6 +793,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 +819,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 +866,7 @@ export const ssType = {
getSong, getSong,
getSongsByGenre, getSongsByGenre,
getStarred, getStarred,
getTranscodeDecision,
internetRadioStation, internetRadioStation,
musicFolderList, musicFolderList,
ping, ping,
+56 -11
View File
@@ -410,16 +410,18 @@ export type Song = {
userRating: null | number; userRating: null | number;
}; };
type ApiContext = {
pathReplace?: string;
pathReplaceWith?: string;
};
type BaseEndpointArgs = { type BaseEndpointArgs = {
apiClientProps: { apiClientProps: {
server?: null | ServerListItemWithCredential; server?: null | ServerListItemWithCredential;
serverId: string; serverId: string;
signal?: AbortSignal; signal?: AbortSignal;
}; };
context?: { context?: ApiContext;
pathReplace?: string;
pathReplaceWith?: string;
};
}; };
type GenreListSortMap = { type GenreListSortMap = {
@@ -1416,11 +1418,10 @@ export type ControllerEndpoint = {
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>; getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>; getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getSongListCount: (args: SongListCountArgs) => Promise<number>; getSongListCount: (args: SongListCountArgs) => Promise<number>;
getStreamUrl: (args: StreamArgs) => string; getStreamUrl: (args: StreamArgs) => Promise<string>;
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;
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>;
@@ -1563,7 +1564,7 @@ export type InternalControllerEndpoint = {
getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>; getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;
getSongList: (args: ReplaceApiClientProps<SongListArgs>) => Promise<SongListResponse>; getSongList: (args: ReplaceApiClientProps<SongListArgs>) => Promise<SongListResponse>;
getSongListCount: (args: ReplaceApiClientProps<SongListCountArgs>) => Promise<number>; getSongListCount: (args: ReplaceApiClientProps<SongListCountArgs>) => Promise<number>;
getStreamUrl: (args: ReplaceApiClientProps<StreamArgs>) => string; getStreamUrl: (args: ReplaceApiClientProps<StreamArgs>) => Promise<string>;
getStructuredLyrics?: ( getStructuredLyrics?: (
args: ReplaceApiClientProps<StructuredLyricsArgs>, args: ReplaceApiClientProps<StructuredLyricsArgs>,
) => Promise<StructuredLyric[]>; ) => Promise<StructuredLyric[]>;
@@ -1667,6 +1668,9 @@ export type StreamQuery = {
bitrate?: number; bitrate?: number;
format?: string; format?: string;
id: string; id: string;
mediaType?: 'podcast' | 'song';
offset?: number;
skipAutoTranscode?: boolean;
transcode: boolean; transcode: boolean;
}; };
@@ -1711,6 +1715,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 = {
@@ -1730,8 +1778,5 @@ type BaseEndpointArgsWithServer = {
serverId: string; serverId: string;
signal?: AbortSignal; signal?: AbortSignal;
}; };
context?: { context?: ApiContext;
pathReplace?: string;
pathReplaceWith?: string;
};
}; };
+1
View File
@@ -7,6 +7,7 @@ export enum ServerFeature {
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect', MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
OS_FORM_POST = 'osFormPost', OS_FORM_POST = 'osFormPost',
OS_TRANSCODE_DECISION = 'osTranscodeDecision',
PLAYLISTS_SMART = 'playlistsSmart', PLAYLISTS_SMART = 'playlistsSmart',
PUBLIC_PLAYLIST = 'publicPlaylist', PUBLIC_PLAYLIST = 'publicPlaylist',
SERVER_PLAY_QUEUE = 'serverPlayQueue', SERVER_PLAY_QUEUE = 'serverPlayQueue',