mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-25 02:37:22 +02:00
111 lines
2.8 KiB
JavaScript
111 lines
2.8 KiB
JavaScript
|
|
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,
|
||
|
|
}
|
||
|
|
}
|