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)