diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index ddd336c0a..974492843 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -734,7 +734,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( @@ -784,20 +786,6 @@ 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 07fe785cf..6460b6787 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -1283,35 +1283,40 @@ export const JellyfinController: InternalControllerEndpoint = { apiClientProps, query: { ...query, limit: 1, startIndex: 0 }, }).then((result) => result!.totalRecordCount!), - getStreamUrl: ({ apiClientProps: { server }, query }) => { - const { bitrate, format, id, offset = 0, transcode, transcodeParams } = query; + getStreamUrl: async ({ apiClientProps: { server }, query }) => { + const { bitrate, format, id, transcode } = query; const deviceId = ''; - if (transcodeParams != null || transcode) { + 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 const realFormat = format || 'mp3'; - let url = + + 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 `${server?.url}/Items/${id}/Download?apiKey=${server?.credential}&playSessionId=${deviceId}`; + return url; }, 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 d8906630d..4a0e398f6 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -941,7 +941,6 @@ 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-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 43ea7788f..a9c136e53 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; 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 +88,151 @@ const ALBUM_LIST_SORT_MAPPING: Record( items: T[], options: { @@ -1805,32 +1951,83 @@ export const SubsonicController: InternalControllerEndpoint = { return totalRecordCount; }, - getStreamUrl: ({ apiClientProps: { server }, query }) => { - const { - bitrate, - format, - id, - mediaType = 'song', - offset = 0, - transcode, - transcodeParams, - } = query; + getStreamUrl: async ({ apiClientProps, context, query }) => { + const { server } = apiClientProps; + const { bitrate, format, id, mediaType = 'song', transcode } = query; + const { serverFeatures, transcode: transcodeSettings } = context || {}; - 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}`; - } + const streamUrl = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`; - let url = `${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; + + // If the server supports transcoding decision, always use it to determine if we need to transcode + if (serverFeatures?.[ServerFeature.OS_TRANSCODE_DECISION]) { + const maxTranscodingAudioBitrate = transcodeSettings?.bitrate || 0; + + const transcodingProfiles = (transcodeSettings?.format || []).map((format) => { + return { + audioCodec: format, + container: format, + maxAudioChannels: 2, + protocol: 'http', + }; + }); + + const transcodeDecision = await ssApiClient(apiClientProps).getTranscodeDecision({ + body: { + codecProfiles: [], + directPlayProfiles: TRANSCODE_DIRECT_PLAY_PROFILES, + maxAudioBitrate: 512000, + maxTranscodingAudioBitrate, + name: 'Feishin', + platform: 'Web', + 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; @@ -1927,32 +2124,6 @@ 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/renderer/features/player/audio-player/engine/mpv-player-engine.tsx b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx index 7e5861b7a..bb329f223 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) : undefined; const nextSongUrl = playerData.nextSong - ? getSongUrl(playerData.nextSong, transcode) + ? await getSongUrl(playerData.nextSong, transcode) : 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) : 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) : 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) : undefined; const nextSongUrl = playerData.nextSong - ? getSongUrl(playerData.nextSong, transcode) + ? await getSongUrl(playerData.nextSong, transcode) : 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..c6c28d4b6 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,46 +11,58 @@ 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) => { + const url = await api.controller.getStreamUrl({ apiClientProps: { serverId: song._serverId }, query: { bitrate: transcode.bitrate, @@ -58,4 +71,6 @@ export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => { transcode: transcode.enabled, }, }); + + return url; }; diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index a8a0d2906..8fd0626c3 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1424,11 +1424,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; - getTranscodeDecision: (args: TranscodeDecisionArgs) => Promise; getUserInfo: (args: UserInfoArgs) => Promise; getUserList?: (args: UserListArgs) => Promise; movePlaylistItem?: (args: MoveItemArgs) => Promise; @@ -1577,9 +1576,6 @@ 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;