mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 21:37:09 +02:00
* 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>
256 lines
8.3 KiB
JavaScript
256 lines
8.3 KiB
JavaScript
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')
|
|
})
|
|
})
|
|
})
|