diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 2dbe7242..35eaee3e 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -33,6 +33,7 @@ import {
replayGainReducer,
downloadMenuDialogReducer,
shareDialogReducer,
+ transcodingReducer,
} from './reducers'
import createAdminStore from './store/createAdminStore'
import { i18nProvider } from './i18n'
@@ -72,6 +73,7 @@ const adminStore = createAdminStore({
activity: activityReducer,
settings: settingsReducer,
replayGain: replayGainReducer,
+ transcoding: transcodingReducer,
},
})
diff --git a/ui/src/actions/player.js b/ui/src/actions/player.js
index acef2e9b..9056abeb 100644
--- a/ui/src/actions/player.js
+++ b/ui/src/actions/player.js
@@ -7,6 +7,8 @@ export const PLAYER_PLAY_TRACKS = 'PLAYER_PLAY_TRACKS'
export const PLAYER_CURRENT = 'PLAYER_CURRENT'
export const PLAYER_SET_VOLUME = 'PLAYER_SET_VOLUME'
export const PLAYER_SET_MODE = 'PLAYER_SET_MODE'
+export const TRANSCODING_SET_PROFILE = 'TRANSCODING_SET_PROFILE'
+export const PLAYER_REFRESH_QUEUE = 'PLAYER_REFRESH_QUEUE'
export const setTrack = (data) => ({
type: PLAYER_SET_TRACK,
@@ -102,3 +104,13 @@ export const setPlayMode = (mode) => ({
type: PLAYER_SET_MODE,
data: { mode },
})
+
+export const setTranscodingProfile = (profile) => ({
+ type: TRANSCODING_SET_PROFILE,
+ data: profile,
+})
+
+export const refreshQueue = (resolvedUrls) => ({
+ type: PLAYER_REFRESH_QUEUE,
+ data: resolvedUrls,
+})
diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx
index 093bb53f..df37edfb 100644
--- a/ui/src/audioplayer/AudioTitle.jsx
+++ b/ui/src/audioplayer/AudioTitle.jsx
@@ -3,6 +3,7 @@ import { useMediaQuery } from '@material-ui/core'
import { Link } from 'react-router-dom'
import clsx from 'clsx'
import { QualityInfo } from '../common'
+import { decisionService } from '../transcode'
import useStyle from './styles'
import { useDrag } from 'react-dnd'
import { DraggableTypes } from '../consts'
@@ -35,6 +36,14 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
rgTrackPeak: song.rgTrackPeak,
}
+ const decision = decisionService.getCachedDecision(audioInfo.trackId)
+ const transcodeProps = decision
+ ? {
+ transcodeStream: decision.transcodeStream || null,
+ isDirectPlay: decision.canDirectPlay,
+ }
+ : {}
+
const subtitle = song.tags?.['subtitle']
const title = song.title + (subtitle ? ` (${subtitle})` : '')
@@ -53,6 +62,7 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
record={qi}
className={classes.qualityInfo}
{...gainInfo}
+ {...transcodeProps}
/>
)}
diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx
index 7d086172..eba3b82d 100644
--- a/ui/src/audioplayer/Player.jsx
+++ b/ui/src/audioplayer/Player.jsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useMediaQuery } from '@material-ui/core'
import { ThemeProvider } from '@material-ui/core/styles'
@@ -19,7 +19,9 @@ import AudioTitle from './AudioTitle'
import {
clearQueue,
currentPlaying,
+ refreshQueue,
setPlayMode,
+ setTranscodingProfile,
setVolume,
syncQueue,
} from '../actions'
@@ -30,6 +32,7 @@ import locale from './locale'
import { keyMap } from '../hotkeys'
import keyHandlers from './keyHandlers'
import { calculateGain } from '../utils/calculateReplayGain'
+import { detectBrowserProfile, decisionService } from '../transcode'
const Player = () => {
const theme = useCurrentTheme()
@@ -49,6 +52,61 @@ const Player = () => {
)
const { authenticated } = useAuthState()
+
+ // Keep a ref to playerState so the mount effect can read the latest value
+ // without re-triggering on every queue/position change
+ const playerStateRef = useRef(playerState)
+ playerStateRef.current = playerState
+
+ // Detect browser codec profile and eagerly resolve transcode URLs for the
+ // persisted queue once on mount (e.g. after a browser refresh)
+ useEffect(() => {
+ const profile = detectBrowserProfile()
+ decisionService.setProfile(profile)
+ dispatch(setTranscodingProfile(profile))
+
+ const state = playerStateRef.current
+ const currentIdx = state.savedPlayIndex || 0
+ const trackIds = state.queue
+ .slice(currentIdx, currentIdx + 4)
+ .filter((item) => !item.isRadio && item.trackId)
+ .map((item) => item.trackId)
+
+ if (trackIds.length === 0) {
+ dispatch(refreshQueue())
+ return
+ }
+
+ Promise.allSettled(
+ trackIds.map((id) =>
+ decisionService.resolveStreamUrl(id).then((url) => [id, url]),
+ ),
+ ).then((results) => {
+ const resolvedUrls = {}
+ results.forEach((r) => {
+ if (r.status === 'fulfilled') {
+ resolvedUrls[r.value[0]] = r.value[1]
+ }
+ })
+ dispatch(refreshQueue(resolvedUrls))
+ })
+ }, [dispatch])
+
+ // Pre-fetch transcode decisions for next 2-3 songs when queue or position changes
+ useEffect(() => {
+ if (!playerState.queue.length) return
+
+ const currentIdx = playerState.savedPlayIndex || 0
+ const nextSongIds = playerState.queue
+ .slice(currentIdx + 1, currentIdx + 4)
+ .filter((item) => !item.isRadio)
+ .map((item) => item.trackId)
+
+ if (nextSongIds.length > 0) {
+ decisionService.prefetchDecisions(nextSongIds)
+ }
+ }, [playerState.queue, playerState.savedPlayIndex])
+
const visible = authenticated && playerState.queue.length > 0
const isRadio = playerState.current?.isRadio || false
const classes = useStyle({
@@ -151,7 +209,9 @@ const Player = () => {
...defaultOptions,
audioLists: playerState.queue.map((item) => item),
playIndex: playerState.playIndex,
- autoPlay: playerState.clear || playerState.playIndex === 0,
+ autoPlay:
+ playerState.autoPlay !== false &&
+ (playerState.clear || playerState.playIndex === 0),
clearPriorAudioLists: playerState.clear,
extendsContent: (
@@ -190,9 +250,9 @@ const Player = () => {
if (!preloaded) {
const next = nextSong()
- if (next != null) {
- const audio = new Audio()
- audio.src = next.musicSrc
+ if (next != null && !next.isRadio) {
+ // Trigger decision pre-fetch (this also warms the cache)
+ decisionService.prefetchDecisions([next.trackId])
}
setPreload(true)
return
@@ -284,6 +344,28 @@ const Player = () => {
}
}, [])
+ const onAudioError = useCallback(
+ (error, currentPlayId, audioLists, audioInfo) => {
+ // Invalidate all cached decisions — token may be stale
+ decisionService.invalidateAll()
+
+ // Pre-fetch decisions for upcoming songs with fresh tokens
+ const currentIdx = playerState.queue.findIndex(
+ (item) => item.uuid === currentPlayId,
+ )
+ if (currentIdx >= 0) {
+ const nextSongIds = playerState.queue
+ .slice(currentIdx + 1, currentIdx + 4)
+ .filter((item) => !item.isRadio)
+ .map((item) => item.trackId)
+ if (nextSongIds.length > 0) {
+ decisionService.prefetchDecisions(nextSongIds)
+ }
+ }
+ },
+ [playerState.queue],
+ )
+
const onBeforeDestroy = useCallback(() => {
return new Promise((resolve, reject) => {
dispatch(clearQueue())
@@ -320,6 +402,7 @@ const Player = () => {
onPlayModeChange={(mode) => dispatch(setPlayMode(mode))}
onAudioEnded={onAudioEnded}
onCoverClick={onCoverClick}
+ onAudioError={onAudioError}
onBeforeDestroy={onBeforeDestroy}
getAudioInstance={setAudioInstance}
/>
diff --git a/ui/src/common/QualityInfo.jsx b/ui/src/common/QualityInfo.jsx
index 171f5e0f..57a8251a 100644
--- a/ui/src/common/QualityInfo.jsx
+++ b/ui/src/common/QualityInfo.jsx
@@ -20,7 +20,15 @@ const useStyle = makeStyles(
},
)
-export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => {
+export const QualityInfo = ({
+ record,
+ size,
+ gainMode,
+ preAmp,
+ className,
+ transcodeStream,
+ isDirectPlay,
+}) => {
const classes = useStyle()
let { suffix, bitRate, rgAlbumGain, rgAlbumPeak, rgTrackGain, rgTrackPeak } =
record
@@ -34,6 +42,20 @@ export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => {
}
}
+ // Show transcode target when transcoding (not direct play)
+ if (transcodeStream && !isDirectPlay) {
+ const targetCodec = (transcodeStream.codec || '').toUpperCase()
+ const targetBitrate = transcodeStream.audioBitrate
+ ? Math.round(transcodeStream.audioBitrate / 1000)
+ : 0
+ let targetInfo = targetCodec
+ if (targetBitrate > 0) {
+ targetInfo += ' ' + targetBitrate
+ }
+ const sourceSuffix = suffix || placeholder
+ info = `${sourceSuffix} → ${targetInfo}`
+ }
+
const extra = useMemo(() => {
if (gainMode !== 'none') {
const gainValue = calculateGain(
@@ -63,6 +85,8 @@ QualityInfo.propTypes = {
size: PropTypes.string,
className: PropTypes.string,
gainMode: PropTypes.string,
+ transcodeStream: PropTypes.object,
+ isDirectPlay: PropTypes.bool,
}
QualityInfo.defaultProps = {
diff --git a/ui/src/common/QualityInfo.test.jsx b/ui/src/common/QualityInfo.test.jsx
index ae187471..174ee8a8 100644
--- a/ui/src/common/QualityInfo.test.jsx
+++ b/ui/src/common/QualityInfo.test.jsx
@@ -77,4 +77,30 @@ describe('', () => {
)
expect(screen.getByText('FLAC (0.00 dB)')).toBeInTheDocument()
})
+
+ it('shows transcode arrow when transcodeStream is provided', () => {
+ const info = { suffix: 'FLAC', bitRate: 1008 }
+ const transcodeStream = { codec: 'opus', audioBitrate: 128000 }
+ render()
+ expect(screen.getByText('FLAC → OPUS 128')).toBeInTheDocument()
+ })
+
+ it('shows transcode with lossy source including bitrate', () => {
+ const info = { suffix: 'FLAC', bitRate: 1008 }
+ const transcodeStream = { codec: 'mp3', audioBitrate: 320000 }
+ render()
+ expect(screen.getByText('FLAC → MP3 320')).toBeInTheDocument()
+ })
+
+ it('does not show arrow when isDirectPlay is true', () => {
+ const info = { suffix: 'MP3', bitRate: 320 }
+ render()
+ expect(screen.getByText('MP3 320')).toBeInTheDocument()
+ })
+
+ it('behaves normally when no transcode props are passed', () => {
+ const info = { suffix: 'MP3', bitRate: 320 }
+ render()
+ expect(screen.getByText('MP3 320')).toBeInTheDocument()
+ })
})
diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js
index 3db0b1df..64a0049b 100644
--- a/ui/src/reducers/index.js
+++ b/ui/src/reducers/index.js
@@ -6,3 +6,4 @@ export * from './albumView'
export * from './activityReducer'
export * from './settingsReducer'
export * from './replayGainReducer'
+export * from './transcodingReducer'
diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js
index 0392736e..b7086e6c 100644
--- a/ui/src/reducers/playerReducer.js
+++ b/ui/src/reducers/playerReducer.js
@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from 'uuid'
import subsonic from '../subsonic'
+import { decisionService } from '../transcode'
import {
PLAYER_ADD_TRACKS,
PLAYER_CLEAR_QUEUE,
@@ -10,6 +11,7 @@ import {
PLAYER_SET_VOLUME,
PLAYER_SYNC_QUEUE,
PLAYER_SET_MODE,
+ PLAYER_REFRESH_QUEUE,
} from '../actions'
import config from '../config'
@@ -30,6 +32,14 @@ const pad = (value) => {
}
}
+const makeMusicSrc = (trackId) =>
+ decisionService.getProfile()
+ ? () =>
+ decisionService
+ .resolveStreamUrl(trackId)
+ .catch(() => subsonic.streamUrl(trackId))
+ : subsonic.streamUrl(trackId)
+
const mapToAudioLists = (item) => {
// If item comes from a playlist, trackId is mediaFileId
const trackId = item.mediaFileId || item.id
@@ -76,7 +86,7 @@ const mapToAudioLists = (item) => {
lyric: lyricText,
singer: item.artist,
duration: item.duration,
- musicSrc: subsonic.streamUrl(trackId),
+ musicSrc: makeMusicSrc(trackId),
cover: subsonic.getCoverArtUrl(
{
id: trackId,
@@ -210,6 +220,22 @@ export const playerReducer = (previousState = initialState, payload) => {
return reduceCurrent(previousState, payload)
case PLAYER_SET_MODE:
return reduceMode(previousState, payload)
+ case PLAYER_REFRESH_QUEUE: {
+ const resolvedUrls = payload.data || {}
+ return {
+ ...previousState,
+ queue: previousState.queue.map((item) => ({
+ ...item,
+ musicSrc: item.isRadio
+ ? item.musicSrc
+ : resolvedUrls[item.trackId] || subsonic.streamUrl(item.trackId),
+ })),
+ clear: true,
+ autoPlay: false,
+ playIndex:
+ previousState.savedPlayIndex >= 0 ? previousState.savedPlayIndex : 0,
+ }
+ }
default:
return previousState
}
diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js
new file mode 100644
index 00000000..9e3b03b1
--- /dev/null
+++ b/ui/src/reducers/playerReducer.test.js
@@ -0,0 +1,52 @@
+import { describe, it, expect } from 'vitest'
+import { playerReducer } from './playerReducer'
+import { PLAYER_REFRESH_QUEUE } from '../actions'
+
+describe('playerReducer', () => {
+ describe('PLAYER_REFRESH_QUEUE', () => {
+ it('clamps negative savedPlayIndex to 0', () => {
+ const state = {
+ queue: [
+ { trackId: 'song-1', musicSrc: 'old-url', uuid: 'a' },
+ { trackId: 'song-2', musicSrc: 'old-url', uuid: 'b' },
+ ],
+ savedPlayIndex: -1,
+ current: {},
+ clear: false,
+ volume: 1,
+ }
+ const action = { type: PLAYER_REFRESH_QUEUE, data: {} }
+ const result = playerReducer(state, action)
+ expect(result.playIndex).toBe(0)
+ })
+
+ it('preserves valid savedPlayIndex', () => {
+ const state = {
+ queue: [
+ { trackId: 'song-1', musicSrc: 'old-url', uuid: 'a' },
+ { trackId: 'song-2', musicSrc: 'old-url', uuid: 'b' },
+ ],
+ savedPlayIndex: 1,
+ current: {},
+ clear: false,
+ volume: 1,
+ }
+ const action = { type: PLAYER_REFRESH_QUEUE, data: {} }
+ const result = playerReducer(state, action)
+ expect(result.playIndex).toBe(1)
+ })
+
+ it('uses savedPlayIndex of 0 correctly', () => {
+ const state = {
+ queue: [{ trackId: 'song-1', musicSrc: 'old-url', uuid: 'a' }],
+ savedPlayIndex: 0,
+ current: {},
+ clear: false,
+ volume: 1,
+ }
+ const action = { type: PLAYER_REFRESH_QUEUE, data: {} }
+ const result = playerReducer(state, action)
+ expect(result.playIndex).toBe(0)
+ })
+ })
+})
diff --git a/ui/src/reducers/transcodingReducer.js b/ui/src/reducers/transcodingReducer.js
new file mode 100644
index 00000000..db7a3708
--- /dev/null
+++ b/ui/src/reducers/transcodingReducer.js
@@ -0,0 +1,14 @@
+import { TRANSCODING_SET_PROFILE } from '../actions'
+
+const initialState = {
+ browserProfile: null,
+}
+
+export const transcodingReducer = (state = initialState, { type, data }) => {
+ switch (type) {
+ case TRANSCODING_SET_PROFILE:
+ return { ...state, browserProfile: data }
+ default:
+ return state
+ }
+}
diff --git a/ui/src/reducers/transcodingReducer.test.js b/ui/src/reducers/transcodingReducer.test.js
new file mode 100644
index 00000000..eb3e7a49
--- /dev/null
+++ b/ui/src/reducers/transcodingReducer.test.js
@@ -0,0 +1,23 @@
+import { describe, it, expect } from 'vitest'
+import { transcodingReducer } from './transcodingReducer'
+import { TRANSCODING_SET_PROFILE } from '../actions'
+
+describe('transcodingReducer', () => {
+ const initialState = { browserProfile: null }
+
+ it('returns initial state', () => {
+ expect(transcodingReducer(undefined, {})).toEqual(initialState)
+ })
+
+ it('handles TRANSCODING_SET_PROFILE', () => {
+ const profile = {
+ name: 'NavidromeUI',
+ directPlayProfiles: [{ containers: ['mp3'] }],
+ }
+ const state = transcodingReducer(initialState, {
+ type: TRANSCODING_SET_PROFILE,
+ data: profile,
+ })
+ expect(state.browserProfile).toEqual(profile)
+ })
+})
diff --git a/ui/src/transcode/browserProfile.js b/ui/src/transcode/browserProfile.js
new file mode 100644
index 00000000..4ee114e4
--- /dev/null
+++ b/ui/src/transcode/browserProfile.js
@@ -0,0 +1,40 @@
+// Each entry: { codec name for the server, container, MIME to probe }
+export 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"' },
+]
+
+// Default transcoding targets — ordered by preference.
+// These are attempted if direct play is not possible.
+const DEFAULT_TRANSCODING_PROFILES = [
+ { container: 'ogg', audioCodec: 'opus', protocol: 'http' },
+ { container: 'mp3', audioCodec: 'mp3', protocol: 'http' },
+]
+
+export function detectBrowserProfile() {
+ const audio = new Audio()
+ const directPlayProfiles = []
+
+ for (const { codec, container, mime } of CODEC_PROBES) {
+ if (audio.canPlayType(mime) === 'probably') {
+ directPlayProfiles.push({
+ containers: [container],
+ audioCodecs: [codec],
+ protocols: ['http'],
+ })
+ }
+ }
+
+ return {
+ name: 'NavidromeUI',
+ platform: navigator.userAgent,
+ directPlayProfiles,
+ transcodingProfiles: DEFAULT_TRANSCODING_PROFILES,
+ codecProfiles: [],
+ }
+}
diff --git a/ui/src/transcode/browserProfile.test.js b/ui/src/transcode/browserProfile.test.js
new file mode 100644
index 00000000..9764a004
--- /dev/null
+++ b/ui/src/transcode/browserProfile.test.js
@@ -0,0 +1,76 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { detectBrowserProfile, CODEC_PROBES } from './browserProfile'
+
+describe('detectBrowserProfile', () => {
+ let mockCanPlayType
+
+ beforeEach(() => {
+ mockCanPlayType = vi.fn()
+ vi.stubGlobal(
+ 'Audio',
+ class {
+ canPlayType = mockCanPlayType
+ },
+ )
+ })
+
+ it('includes codecs that return "probably"', () => {
+ mockCanPlayType.mockImplementation((mime) => {
+ if (mime === 'audio/mpeg') return 'probably'
+ if (mime === 'audio/ogg; codecs="opus"') return 'probably'
+ return ''
+ })
+
+ const profile = detectBrowserProfile()
+
+ expect(profile.name).toBe('NavidromeUI')
+ expect(profile.directPlayProfiles.length).toBe(2)
+
+ const codecs = profile.directPlayProfiles.flatMap((p) => p.audioCodecs)
+ expect(codecs).toContain('mp3')
+ expect(codecs).toContain('opus')
+ })
+
+ it('excludes codecs that return "maybe"', () => {
+ mockCanPlayType.mockReturnValue('maybe')
+
+ const profile = detectBrowserProfile()
+ expect(profile.directPlayProfiles).toEqual([])
+ })
+
+ it('excludes codecs that return empty string', () => {
+ mockCanPlayType.mockReturnValue('')
+
+ const profile = detectBrowserProfile()
+ expect(profile.directPlayProfiles).toEqual([])
+ })
+
+ it('sets protocol to "http" for all direct play profiles', () => {
+ mockCanPlayType.mockReturnValue('probably')
+
+ const profile = detectBrowserProfile()
+ profile.directPlayProfiles.forEach((p) => {
+ expect(p.protocols).toEqual(['http'])
+ })
+ })
+
+ it('includes transcoding profiles for common formats', () => {
+ mockCanPlayType.mockReturnValue('')
+
+ const profile = detectBrowserProfile()
+ expect(profile.transcodingProfiles.length).toBeGreaterThan(0)
+ expect(profile.transcodingProfiles[0].protocol).toBe('http')
+ })
+
+ it('sets codecProfiles to empty array', () => {
+ mockCanPlayType.mockReturnValue('probably')
+
+ const profile = detectBrowserProfile()
+ expect(profile.codecProfiles).toEqual([])
+ })
+
+ it('includes platform info', () => {
+ const profile = detectBrowserProfile()
+ expect(typeof profile.platform).toBe('string')
+ })
+})
diff --git a/ui/src/transcode/decisionService.js b/ui/src/transcode/decisionService.js
new file mode 100644
index 00000000..9228cc88
--- /dev/null
+++ b/ui/src/transcode/decisionService.js
@@ -0,0 +1,111 @@
+import { jwtDecode } from 'jwt-decode'
+import subsonic from '../subsonic'
+import { baseUrl } from '../utils'
+
+// Decode the exp claim from a JWT token (no signature verification needed client-side).
+// The JWT token is meant to be opaque to the client, we are only allowing ourselves to do
+// this here because the UI is tightly integrated with the server; normally we would
+// need to rely on the getTranscodeStream returning an error on stale tokens.
+export function decodeJwtExp(token) {
+ try {
+ if (!token) return null
+ const payload = jwtDecode(token)
+ return typeof payload.exp === 'number' ? payload.exp : null
+ } catch {
+ return null
+ }
+}
+
+export function createDecisionService(fetchFn) {
+ const cache = new Map()
+ let currentProfile = null
+
+ function isFresh(entry) {
+ const exp = decodeJwtExp(entry.decision?.transcodeParams)
+ if (exp == null) return false
+ // exp is in seconds, Date.now() in milliseconds; 60s buffer avoids mid-request expiry
+ return Date.now() < (exp - 60) * 1000
+ }
+
+ function setProfile(profile) {
+ currentProfile = profile
+ }
+
+ function getProfile() {
+ return currentProfile
+ }
+
+ async function getDecision(songId, browserProfile) {
+ const profile = browserProfile || currentProfile
+ if (!profile) return null
+
+ const cached = cache.get(songId)
+ if (cached && isFresh(cached)) {
+ return cached.decision
+ }
+
+ const decision = await fetchFn(songId, profile)
+ cache.set(songId, { decision })
+ return decision
+ }
+
+ async function prefetchDecisions(songIds, browserProfile) {
+ const profile = browserProfile || currentProfile
+ if (!profile) return
+
+ const uncached = songIds.filter((id) => {
+ const entry = cache.get(id)
+ return !entry || !isFresh(entry)
+ })
+
+ await Promise.allSettled(
+ uncached.map(async (id) => {
+ const decision = await fetchFn(id, profile)
+ cache.set(id, { decision })
+ }),
+ )
+ }
+
+ function invalidateAll() {
+ cache.clear()
+ }
+
+ function buildStreamUrl(songId, transcodeParams, offset) {
+ const params = {
+ mediaId: songId,
+ mediaType: 'song',
+ transcodeParams,
+ }
+ if (offset != null && offset > 0) {
+ params.offset = offset
+ }
+ return baseUrl(subsonic.url('getTranscodeStream', null, params))
+ }
+
+ async function resolveStreamUrl(songId) {
+ const decision = await getDecision(songId)
+ if (!decision?.transcodeParams) {
+ return baseUrl(subsonic.streamUrl(songId))
+ }
+ return buildStreamUrl(songId, decision.transcodeParams)
+ }
+
+ function getCachedDecision(songId) {
+ const entry = cache.get(songId)
+ if (entry && isFresh(entry)) {
+ return entry.decision
+ }
+ return null
+ }
+
+ return {
+ getDecision,
+ getCachedDecision,
+ prefetchDecisions,
+ resolveStreamUrl,
+ invalidateAll,
+ buildStreamUrl,
+ setProfile,
+ getProfile,
+ }
+}
diff --git a/ui/src/transcode/decisionService.test.js b/ui/src/transcode/decisionService.test.js
new file mode 100644
index 00000000..a2718ade
--- /dev/null
+++ b/ui/src/transcode/decisionService.test.js
@@ -0,0 +1,256 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
+import { createDecisionService, decodeJwtExp } from './decisionService'
+
+// Helper: create a fake JWT with a given exp (seconds since epoch)
+function fakeJwt(expSeconds) {
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
+ const payload = btoa(JSON.stringify({ exp: expSeconds }))
+ return `${header}.${payload}.fake-signature`
+}
+
+// Helper: create a fake JWT with no exp claim
+function fakeJwtNoExp() {
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
+ const payload = btoa(JSON.stringify({ sub: 'test' }))
+ return `${header}.${payload}.fake-signature`
+}
+
+describe('decodeJwtExp', () => {
+ it('extracts exp from a valid JWT', () => {
+ const exp = 1700000000
+ expect(decodeJwtExp(fakeJwt(exp))).toBe(exp)
+ })
+
+ it('returns null for JWT without exp claim', () => {
+ expect(decodeJwtExp(fakeJwtNoExp())).toBeNull()
+ })
+
+ it('returns null for non-JWT string', () => {
+ expect(decodeJwtExp('not-a-jwt')).toBeNull()
+ })
+
+ it('returns null for empty string', () => {
+ expect(decodeJwtExp('')).toBeNull()
+ })
+
+ it('returns null for null/undefined', () => {
+ expect(decodeJwtExp(null)).toBeNull()
+ expect(decodeJwtExp(undefined)).toBeNull()
+ })
+})
+
+describe('decisionService', () => {
+ let service
+ let mockFetchFn
+
+ const fakeProfile = {
+ name: 'NavidromeUI',
+ platform: 'test',
+ directPlayProfiles: [],
+ transcodingProfiles: [],
+ codecProfiles: [],
+ }
+
+ // Token that expires 1 hour from "now" (will be relative to fake timers)
+ function makeFakeDecision(expiresInMs = 3600 * 1000) {
+ const expSeconds = Math.floor((Date.now() + expiresInMs) / 1000)
+ return {
+ canDirectPlay: true,
+ canTranscode: false,
+ transcodeParams: fakeJwt(expSeconds),
+ sourceStream: { codec: 'mp3', container: 'mp3' },
+ }
+ }
+
+ beforeEach(() => {
+ localStorage.setItem('username', 'testuser')
+ localStorage.setItem('subsonic-token', 'testtoken')
+ localStorage.setItem('subsonic-salt', 'testsalt')
+ mockFetchFn = vi.fn().mockImplementation(() => {
+ return Promise.resolve(makeFakeDecision())
+ })
+ service = createDecisionService(mockFetchFn)
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ localStorage.clear()
+ })
+
+ describe('getDecision', () => {
+ it('fetches and caches a decision', async () => {
+ const result = await service.getDecision('song-1', fakeProfile)
+ expect(result.canDirectPlay).toBe(true)
+ expect(mockFetchFn).toHaveBeenCalledTimes(1)
+ expect(mockFetchFn).toHaveBeenCalledWith('song-1', fakeProfile)
+
+ // Second call uses cache
+ const result2 = await service.getDecision('song-1', fakeProfile)
+ expect(result2).toEqual(result)
+ expect(mockFetchFn).toHaveBeenCalledTimes(1)
+ })
+
+ it('re-fetches after token expires', async () => {
+ vi.useFakeTimers()
+
+ // Token expires in 1 hour
+ mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000))
+ await service.getDecision('song-1', fakeProfile)
+ expect(mockFetchFn).toHaveBeenCalledTimes(1)
+
+ // Advance past expiration
+ vi.advanceTimersByTime(3600 * 1000 + 1000)
+ await service.getDecision('song-1', fakeProfile)
+ expect(mockFetchFn).toHaveBeenCalledTimes(2)
+ vi.useRealTimers()
+ })
+
+ it('does not re-fetch before token expires', async () => {
+ vi.useFakeTimers()
+
+ // Token expires in 1 hour
+ mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000))
+ await service.getDecision('song-1', fakeProfile)
+
+ // 30 minutes later — still fresh
+ vi.advanceTimersByTime(1800 * 1000)
+ await service.getDecision('song-1', fakeProfile)
+ expect(mockFetchFn).toHaveBeenCalledTimes(1)
+ vi.useRealTimers()
+ })
+
+ it('re-fetches immediately when token has no exp claim', async () => {
+ const noExpDecision = {
+ canDirectPlay: true,
+ canTranscode: false,
+ transcodeParams: fakeJwtNoExp(),
+ sourceStream: { codec: 'mp3', container: 'mp3' },
+ }
+ mockFetchFn.mockResolvedValue(noExpDecision)
+ await service.getDecision('song-1', fakeProfile)
+
+ // Should re-fetch because token has no exp
+ await service.getDecision('song-1', fakeProfile)
+ expect(mockFetchFn).toHaveBeenCalledTimes(2)
+ })
+
+ it('caches different songs independently', async () => {
+ await service.getDecision('song-1', fakeProfile)
+ await service.getDecision('song-2', fakeProfile)
+ expect(mockFetchFn).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('getCachedDecision', () => {
+ it('returns null when song is not cached', () => {
+ expect(service.getCachedDecision('song-1')).toBeNull()
+ })
+
+ it('returns cached decision after getDecision', async () => {
+ await service.getDecision('song-1', fakeProfile)
+ const cached = service.getCachedDecision('song-1')
+ expect(cached).not.toBeNull()
+ expect(cached.canDirectPlay).toBe(true)
+ })
+
+ it('returns null after cache is invalidated', async () => {
+ await service.getDecision('song-1', fakeProfile)
+ service.invalidateAll()
+ expect(service.getCachedDecision('song-1')).toBeNull()
+ })
+
+ it('returns null after token expires', async () => {
+ vi.useFakeTimers()
+ mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000))
+ await service.getDecision('song-1', fakeProfile)
+
+ vi.advanceTimersByTime(3600 * 1000 + 1000)
+ expect(service.getCachedDecision('song-1')).toBeNull()
+ vi.useRealTimers()
+ })
+ })
+
+ describe('prefetchDecisions', () => {
+ it('fetches decisions for uncached songs', async () => {
+ await service.prefetchDecisions(['song-1', 'song-2'], fakeProfile)
+ expect(mockFetchFn).toHaveBeenCalledTimes(2)
+ })
+
+ it('skips already cached songs', async () => {
+ await service.getDecision('song-1', fakeProfile)
+ mockFetchFn.mockClear()
+
+ await service.prefetchDecisions(['song-1', 'song-2'], fakeProfile)
+ expect(mockFetchFn).toHaveBeenCalledTimes(1)
+ expect(mockFetchFn).toHaveBeenCalledWith('song-2', fakeProfile)
+ })
+
+ it('silently ignores fetch errors', async () => {
+ mockFetchFn.mockRejectedValue(new Error('network error'))
+ await expect(
+ service.prefetchDecisions(['song-1'], fakeProfile),
+ ).resolves.not.toThrow()
+ })
+ })
+
+ describe('invalidateAll', () => {
+ it('clears cache so next getDecision re-fetches', async () => {
+ await service.getDecision('song-1', fakeProfile)
+ expect(mockFetchFn).toHaveBeenCalledTimes(1)
+
+ service.invalidateAll()
+
+ await service.getDecision('song-1', fakeProfile)
+ expect(mockFetchFn).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('resolveStreamUrl', () => {
+ it('fetches decision and returns built URL', async () => {
+ service.setProfile(fakeProfile)
+ const url = await service.resolveStreamUrl('song-1')
+ expect(url).toContain('getTranscodeStream')
+ expect(url).toContain('mediaId=song-1')
+ expect(mockFetchFn).toHaveBeenCalledTimes(1)
+ })
+
+ it('falls back to stream URL when decision has no transcodeParams', async () => {
+ service.setProfile(fakeProfile)
+ mockFetchFn.mockResolvedValue({
+ canDirectPlay: true,
+ canTranscode: false,
+ })
+ const url = await service.resolveStreamUrl('song-1')
+ expect(url).toContain('stream')
+ expect(url).not.toContain('getTranscodeStream')
+ })
+
+ it('falls back to stream URL when decision is null', async () => {
+ service.setProfile(fakeProfile)
+ mockFetchFn.mockResolvedValue(null)
+ const url = await service.resolveStreamUrl('song-1')
+ expect(url).toContain('stream')
+ expect(url).not.toContain('getTranscodeStream')
+ })
+ })
+
+ describe('buildStreamUrl', () => {
+ it('builds URL with required parameters', () => {
+ const url = service.buildStreamUrl('song-1', 'jwt-token-123')
+ expect(url).toContain('getTranscodeStream')
+ expect(url).toContain('mediaId=song-1')
+ expect(url).toContain('mediaType=song')
+ expect(url).toContain('transcodeParams=jwt-token-123')
+ })
+
+ it('includes offset when provided', () => {
+ const url = service.buildStreamUrl('song-1', 'jwt-token-123', 30)
+ expect(url).toContain('offset=30')
+ })
+
+ it('omits offset when not provided', () => {
+ const url = service.buildStreamUrl('song-1', 'jwt-token-123')
+ expect(url).not.toContain('offset')
+ })
+ })
+})
diff --git a/ui/src/transcode/fetchDecision.js b/ui/src/transcode/fetchDecision.js
new file mode 100644
index 00000000..1c794082
--- /dev/null
+++ b/ui/src/transcode/fetchDecision.js
@@ -0,0 +1,23 @@
+import subsonic from '../subsonic'
+import { httpClient } from '../dataProvider'
+
+export async function fetchTranscodeDecision(songId, browserProfile) {
+ const fetchUrl = subsonic.url('getTranscodeDecision', null, {
+ mediaId: songId,
+ mediaType: 'song',
+ })
+
+ const { json } = await httpClient(fetchUrl, {
+ method: 'POST',
+ body: JSON.stringify(browserProfile),
+ })
+
+ const subsonicResponse = json['subsonic-response']
+
+ if (subsonicResponse.status !== 'ok') {
+ const err = subsonicResponse.error || {}
+ throw new Error(`getTranscodeDecision error: ${err.code} ${err.message}`)
+ }
+
+ return subsonicResponse.transcodeDecision
+}
diff --git a/ui/src/transcode/fetchDecision.test.js b/ui/src/transcode/fetchDecision.test.js
new file mode 100644
index 00000000..83f934f3
--- /dev/null
+++ b/ui/src/transcode/fetchDecision.test.js
@@ -0,0 +1,92 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
+
+// Mock httpClient before importing module under test
+vi.mock('../dataProvider', () => ({
+ httpClient: vi.fn(),
+}))
+
+import { fetchTranscodeDecision } from './fetchDecision'
+import { httpClient } from '../dataProvider'
+
+describe('fetchTranscodeDecision', () => {
+ const fakeProfile = {
+ name: 'NavidromeUI',
+ platform: 'test',
+ directPlayProfiles: [
+ { containers: ['mp3'], audioCodecs: ['mp3'], protocols: ['http'] },
+ ],
+ transcodingProfiles: [],
+ codecProfiles: [],
+ }
+
+ const fakeJson = {
+ 'subsonic-response': {
+ status: 'ok',
+ transcodeDecision: {
+ canDirectPlay: true,
+ canTranscode: false,
+ transcodeParams: 'jwt-token',
+ sourceStream: { codec: 'mp3' },
+ },
+ },
+ }
+
+ beforeEach(() => {
+ localStorage.setItem('username', 'testuser')
+ localStorage.setItem('subsonic-token', 'testtoken')
+ localStorage.setItem('subsonic-salt', 'testsalt')
+
+ httpClient.mockResolvedValue({ json: fakeJson })
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ localStorage.clear()
+ })
+
+ it('makes a POST request to getTranscodeDecision with correct URL', async () => {
+ await fetchTranscodeDecision('song-1', fakeProfile)
+
+ expect(httpClient).toHaveBeenCalledTimes(1)
+ const [url, options] = httpClient.mock.calls[0]
+ expect(url).toContain('getTranscodeDecision')
+ expect(url).toContain('mediaId=song-1')
+ expect(url).toContain('mediaType=song')
+ expect(options.method).toBe('POST')
+ })
+
+ it('sends the browser profile as JSON body', async () => {
+ await fetchTranscodeDecision('song-1', fakeProfile)
+
+ const [, options] = httpClient.mock.calls[0]
+ expect(JSON.parse(options.body)).toEqual(fakeProfile)
+ })
+
+ it('returns the transcodeDecision from response', async () => {
+ const result = await fetchTranscodeDecision('song-1', fakeProfile)
+ expect(result).toEqual(fakeJson['subsonic-response'].transcodeDecision)
+ })
+
+ it('throws on HTTP error (httpClient rejects)', async () => {
+ httpClient.mockRejectedValue(new Error('Server Error'))
+
+ await expect(
+ fetchTranscodeDecision('song-1', fakeProfile),
+ ).rejects.toThrow()
+ })
+
+ it('throws on Subsonic error response', async () => {
+ httpClient.mockResolvedValue({
+ json: {
+ 'subsonic-response': {
+ status: 'failed',
+ error: { code: 70, message: 'not found' },
+ },
+ },
+ })
+
+ await expect(
+ fetchTranscodeDecision('song-1', fakeProfile),
+ ).rejects.toThrow()
+ })
+})
diff --git a/ui/src/transcode/index.js b/ui/src/transcode/index.js
new file mode 100644
index 00000000..aa0cb216
--- /dev/null
+++ b/ui/src/transcode/index.js
@@ -0,0 +1,5 @@
+import { createDecisionService } from './decisionService'
+import { fetchTranscodeDecision } from './fetchDecision'
+export { detectBrowserProfile } from './browserProfile'
+
+export const decisionService = createDecisionService(fetchTranscodeDecision)