🏡 index : quine-global/quintodrome.git

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,
  }
}