feat(ui): integrate transcode decision into web player (#5155)

* feat(ui): add browser audio profile detection for transcoding

Detect browser codec capabilities via canPlayType() to build a client
profile for the getTranscodeDecision API. Only codecs returning "probably"
are treated as supported for conservative compatibility.

* feat(ui): add transcode decision service with caching and pre-fetch

Standalone service that fetches getTranscodeDecision results, caches
them with an 11-hour TTL (1h buffer before 12h token expiry), and
supports bulk pre-fetching for upcoming queue items. Includes
invalidateAll() for handling stale tokens and getCachedDecision()
for synchronous cache reads.

* feat(ui): add fetch helper for getTranscodeDecision endpoint

POST-based Subsonic API call that sends the browser's codec profile
and returns the transcode decision including the JWT transcodeParams
token for subsequent streaming.

* feat(ui): wire transcode decision service singleton

Module index that creates the service singleton with the real fetch
function and re-exports the browser profile detector.

* feat(ui): add Redux transcoding reducer for browser profile state

Store the detected browser codec profile in Redux so it's available
globally. The profile is set once at startup and used by the decision
service when calling getTranscodeDecision.

* feat(ui): integrate transcode decision into player musicSrc

Replace static stream URLs with lazy musicSrc functions that fetch
a transcode decision before playback. Falls back to the old stream
endpoint if the decision fetch fails or if no browser profile is set.

* feat(ui): detect browser profile and pre-fetch transcode decisions

Run codec detection once when the Player mounts, storing the profile
in both the decision service and Redux. Pre-fetch decisions for the
next 3 songs when the queue or play position changes.

* feat(ui): handle stale tokens and replace audio preload with decision pre-fetch

On audio playback error, invalidate all cached transcode decisions
and pre-fetch fresh decisions for upcoming songs. Replace the old
Audio element preload with decision pre-fetching to warm the cache
for instant playback transitions.

* feat(ui): show transcode format in QualityInfo chip

When transcode decision data is available, QualityInfo now shows
"FLAC → OPUS 128" instead of just the source format. The new props
are optional, so existing usages in song lists, album songs, playlists,
and shares are unaffected.

* feat(ui): display transcode status in player quality badge

AudioTitle now reads the cached transcode decision for the current
track and passes it to QualityInfo, showing "FLAC → OPUS 128" when
transcoding or the normal format when direct playing.

* chore(ui): format and lint transcode decision integration

* refactor(ui): use JWT exp claim for decision cache expiry

Replace the hardcoded 11-hour TTL with actual token expiration
decoded from the JWT's exp claim. Each cache entry is now validated
against its own token's lifetime, adapting automatically to server
configuration changes. Tokens without an exp claim are treated as
expired and re-fetched immediately.

* fix(ui): resolve transcode URLs eagerly on browser refresh

Instead of setting musicSrc to a function on queue refresh (which
breaks the player's identity matching and can't survive JSON
serialization), resolve transcode decisions for the current and
next few tracks before dispatching, passing string URLs to the
reducer.

Also simplifies code: extract makeMusicSrc helper, add
resolveStreamUrl to decisionService, use httpClient instead of
raw fetch, and remove barrel file test.

* chore(ui): fix prettier formatting in Player.jsx

* fix(ui): use ref to avoid stale closure in mount-only transcode effect

Split the mount effect into profile detection + URL resolution, using a
ref for playerState so the effect correctly reads the latest queue without
needing playerState in the dependency array (which would cause it to
re-run on every queue/position change).

* fix(ui): address code review feedback on transcode integration

- Use jwt-decode for JWT parsing instead of manual atob (handles base64url)
- Guard resolveStreamUrl to fall back to direct stream when decision is null
- Fix savedPlayIndex -1 bug in PLAYER_REFRESH_QUEUE (findIndex returns -1)

* docs: improve comments on JWT exp claim decoding in decision service

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-03-09 11:06:31 -04:00
committed by GitHub
parent a25306f2c1
commit 957130ca38
18 changed files with 883 additions and 7 deletions
+2
View File
@@ -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,
},
})
+12
View File
@@ -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,
})
+10
View File
@@ -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}
/>
)}
</span>
+88 -5
View File
@@ -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: (
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
@@ -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}
/>
+25 -1
View File
@@ -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 = {
+26
View File
@@ -77,4 +77,30 @@ describe('<QualityInfo />', () => {
)
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(<QualityInfo record={info} transcodeStream={transcodeStream} />)
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(<QualityInfo record={info} transcodeStream={transcodeStream} />)
expect(screen.getByText('FLAC → MP3 320')).toBeInTheDocument()
})
it('does not show arrow when isDirectPlay is true', () => {
const info = { suffix: 'MP3', bitRate: 320 }
render(<QualityInfo record={info} isDirectPlay={true} />)
expect(screen.getByText('MP3 320')).toBeInTheDocument()
})
it('behaves normally when no transcode props are passed', () => {
const info = { suffix: 'MP3', bitRate: 320 }
render(<QualityInfo record={info} />)
expect(screen.getByText('MP3 320')).toBeInTheDocument()
})
})
+1
View File
@@ -6,3 +6,4 @@ export * from './albumView'
export * from './activityReducer'
export * from './settingsReducer'
export * from './replayGainReducer'
export * from './transcodingReducer'
+27 -1
View File
@@ -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
}
+52
View File
@@ -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)
})
})
})
+14
View File
@@ -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
}
}
@@ -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)
})
})
+40
View File
@@ -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: [],
}
}
+76
View File
@@ -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')
})
})
+111
View File
@@ -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,
}
}
+256
View File
@@ -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')
})
})
})
+23
View File
@@ -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
}
+92
View File
@@ -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()
})
})
+5
View File
@@ -0,0 +1,5 @@
import { createDecisionService } from './decisionService'
import { fetchTranscodeDecision } from './fetchDecision'
export { detectBrowserProfile } from './browserProfile'
export const decisionService = createDecisionService(fetchTranscodeDecision)