Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/common/src/services/audio-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export enum AudioError {

export type AudioPlayer = {
audio: HTMLAudioElement
load: (duration: number, onEnd: () => void, mp3Url: Nullable<string>) => void
load: (
duration: number,
onEnd: () => void,
mp3Url: Nullable<string>,
loadTimeoutMs?: number
) => void
play: () => void
pause: () => void
stop: () => void
Expand Down
4 changes: 4 additions & 0 deletions packages/common/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export const MESSAGE_GROUP_THRESHOLD_MINUTES = 2
// Minimum time spent buffering until we show visual indicators (loading spinners, etc)
// Intended to avoid flickering buffer states and avoid showing anything at all if the buffer is short & barely noticeable
export const MIN_BUFFERING_DELAY_MS = 1000

// Maximum time to wait for an audio request to start loading before trying next mirror
// Matches the longest cascading timeout phase (30s)
export const AUDIO_LOAD_TIMEOUT_MS = 30000
export const TEMPORARY_PASSWORD = 'TemporaryPassword'

export const AUDIO_MATCHING_REWARDS_MULTIPLIER = 1
2 changes: 2 additions & 0 deletions packages/common/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export * from './wallet'
export * from './sagaHelpers'
export * from './fileUtil'
export * from './constants'
export * from './resolveStreamUrl'
export * from './resolveUrlWithCascadingTimeout'
export * from './stringUtils'
export * from './challenges'
export * as creativeCommons from './creativeCommons'
Expand Down
32 changes: 5 additions & 27 deletions packages/common/src/utils/resolveImageUrl.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,15 @@
import { SquareSizes, WidthSizes } from '~/models/ImageSizes'
import { Maybe } from '~/utils/typeUtils'

import { resolveUrlWithCascadingTimeout } from './resolveUrlWithCascadingTimeout'

type Artwork<T extends string | number | symbol> = { [key in T]?: string } & {
mirrors?: string[] | undefined
}

const tryUrl = async (url: string): Promise<boolean> => {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const response = await fetch(url, {
method: 'HEAD',
signal: controller.signal
})
clearTimeout(timeoutId)
return response.ok
} catch {
return false
}
}

const tryUrls = async (urls: string[]): Promise<string> => {
for (const url of urls) {
if (await tryUrl(url)) {
return url
}
}
return urls[0] ?? ''
}

/**
* Resolves an image URL from an artwork object, handling mirrors and fallbacks.
* This is a non-React version of the logic in useImageSize hook.
* Resolves an image URL from an artwork object using cascading timeouts:
* try primary (2s) → mirrors (2s) → mirrors (5s) → mirrors (30s)
*
* @param artwork - The artwork object containing size URLs and optional mirrors
* @param targetSize - The desired size of the image
Expand Down Expand Up @@ -72,6 +50,6 @@ export const resolveImageUrl = async <
}
}

const workingUrl = await tryUrls(urlsToTry)
const workingUrl = await resolveUrlWithCascadingTimeout(urlsToTry)
return workingUrl || defaultImage
}
34 changes: 34 additions & 0 deletions packages/common/src/utils/resolveStreamUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { resolveUrlWithCascadingTimeout } from './resolveUrlWithCascadingTimeout'

type StreamObject = { url?: string; mirrors?: string[] }

/**
* Resolves a working stream URL from a stream object using cascading timeouts:
* try primary (2s) → mirrors (2s) → mirrors (5s) → mirrors (30s)
*
* @param streamObj - The stream or preview object with url and mirrors
* @param skipCount - Number of URLs to skip (for retries after playback error)
*/
export const resolveStreamUrl = async (
streamObj: StreamObject | null | undefined,
skipCount = 0
): Promise<string | null> => {
if (!streamObj?.url) {
return null
}

const urlsToTry: string[] = [streamObj.url]
if (streamObj.mirrors) {
for (const mirror of streamObj.mirrors) {
try {
const mirrorUrl = new URL(streamObj.url)
mirrorUrl.hostname = new URL(mirror).hostname
urlsToTry.push(mirrorUrl.toString())
} catch {
// no-op
}
}
}

return resolveUrlWithCascadingTimeout(urlsToTry, skipCount)
}
69 changes: 69 additions & 0 deletions packages/common/src/utils/resolveUrlWithCascadingTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Cascading timeout phases for URL resolution.
* Each phase tries primary + all mirrors with the given timeout:
* - Phase 1: 2s
* - Phase 2: 5s
* - Phase 3: 30s
*/
export const CASCADING_TIMEOUTS_MS = [2000, 5000, 30000] as const

const tryUrlWithTimeout = async (
url: string,
timeoutMs: number
): Promise<boolean> => {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
const response = await fetch(url, {
method: 'HEAD',
signal: controller.signal
})
clearTimeout(timeoutId)
return response.ok
} catch {
return false
}
}

const tryUrlsWithTimeout = async (
urls: string[],
timeoutMs: number
): Promise<string | null> => {
for (const url of urls) {
if (await tryUrlWithTimeout(url, timeoutMs)) {
return url
}
}
return null
}

/**
* Resolves a working URL using cascading timeouts.
* Each phase tries primary + all mirrors with progressively longer timeouts:
* - Phase 1: all urls with 2s
* - Phase 2: all urls with 5s
* - Phase 3: all urls with 30s
*
* @param urls - [primary, ...mirrors]
* @param skipCount - Number of URLs to skip from the start (for retries after error)
*/
export const resolveUrlWithCascadingTimeout = async (
urls: string[],
skipCount = 0
): Promise<string | null> => {
const urlsToTry = urls.slice(skipCount)
if (urlsToTry.length === 0) {
return null
}

const primaryFallback = urlsToTry[0] ?? null

for (const timeoutMs of CASCADING_TIMEOUTS_MS) {
const workingUrl = await tryUrlsWithTimeout(urlsToTry, timeoutMs)
if (workingUrl) {
return workingUrl
}
}

return primaryFallback
}
15 changes: 6 additions & 9 deletions packages/mobile/src/components/audio/AudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ import type { Queueable, CommonState } from '@audius/common/store'
import {
Genre,
removeNullable,
getTrackPreviewDuration
getTrackPreviewDuration,
resolveStreamUrl
} from '@audius/common/utils'
import type { Nullable } from '@audius/common/utils'
import { Id, OptionalId } from '@audius/sdk'
import { getMirrorStreamUrl } from '@audius/web/src/common/store/player/sagas'
import { isEqual, uniq } from 'lodash'
import TrackPlayer, {
AppKilledPlaybackBehavior,
Expand Down Expand Up @@ -319,16 +319,13 @@ export const AudioPlayer = () => {
// Get Track url
let url: string

const contentNodeStreamUrl = getMirrorStreamUrl(
track,
shouldPreview,
retries ?? 0
)
const streamObj = shouldPreview ? track.preview : track.stream
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this changing?

if (offlineTrackAvailable && isCollectionMarkedForDownload) {
const audioFilePath = getLocalAudioPath(trackId)
url = `file://${audioFilePath}`
} else if (contentNodeStreamUrl) {
url = contentNodeStreamUrl
} else if (streamObj?.url) {
url =
(await resolveStreamUrl(streamObj, retries ?? 0)) ?? streamObj.url
} else {
const sdk = await audiusSdk()
const nftAccessSignature = nftAccessSignatureMap[trackId]?.mp3 ?? null
Expand Down
71 changes: 61 additions & 10 deletions packages/web/src/common/store/player/sagas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
queueSelectors
} from '@audius/common/store'
import {
CASCADING_TIMEOUTS_MS,
Genre,
actionChannelDispatcher,
getTrackPreviewDuration,
Expand Down Expand Up @@ -88,6 +89,49 @@ export const getMirrorStreamUrl = (
return streamObj?.url ?? null
}

/**
* Returns the stream URL and load timeout for the given retry index.
* Uses the cascading pattern - each phase tries primary + all mirrors:
* - Phase 0: all urls with 2s
* - Phase 1: all urls with 5s
* - Phase 2: all urls with 30s
* No HEAD fetch - the audio element does the actual request.
*/
const getStreamUrlAndTimeout = (
streamObj: { url?: string; mirrors?: string[] } | null | undefined,
retries: number
): { url: string | null; timeoutMs: number } => {
const defaultTimeout = CASCADING_TIMEOUTS_MS[2]
if (!streamObj?.url) {
return { url: null, timeoutMs: defaultTimeout }
}

const urls: string[] = [streamObj.url]
const mirrors = streamObj.mirrors ?? []
for (const mirror of mirrors) {
try {
const mirrorUrl = new URL(streamObj.url)
mirrorUrl.hostname = new URL(mirror).hostname
urls.push(mirrorUrl.toString())
} catch {
// no-op
}
}

const urlsPerPhase = urls.length
const maxRetries = urlsPerPhase * 3

if (retries >= maxRetries) {
return { url: urls[0], timeoutMs: defaultTimeout }
}

const phase = Math.floor(retries / urlsPerPhase)
const urlIndex = retries % urlsPerPhase
const timeoutMs = CASCADING_TIMEOUTS_MS[phase]

return { url: urls[urlIndex], timeoutMs }
}

export function* watchPlay() {
yield* takeLatest(play.type, function* (action: ReturnType<typeof play>) {
const { uid, trackId, playerBehavior, startTime, onEnd, retries } =
Expand Down Expand Up @@ -137,16 +181,16 @@ export function* watchPlay() {
trackDuration = getTrackPreviewDuration(track)
}

const contentNodeStreamUrl = getMirrorStreamUrl(
track,
shouldPreview,
retries ?? 0
)
const streamObj = shouldPreview ? track.preview : track.stream
const { url: contentNodeStreamUrl, timeoutMs: loadTimeoutMs } =
streamObj?.url
? getStreamUrlAndTimeout(streamObj, retries ?? 0)
: { url: null as string | null, timeoutMs: 30000 }

const isLongFormContent =
track.genre === Genre.Podcasts || track.genre === Genre.Audiobooks

const createEndChannel = async (url: string) => {
const createEndChannel = async (url: string, timeoutMs?: number) => {
const endChannel = eventChannel((emitter) => {
audioPlayer.load(
trackDuration ||
Expand Down Expand Up @@ -174,7 +218,8 @@ export function* watchPlay() {
)
}
},
url
url,
timeoutMs
)
return () => {}
})
Expand All @@ -185,7 +230,11 @@ export function* watchPlay() {
// If we have a stream URL from API already for content node, use that.
// If not, we might need the NFT gated signature, so fallback to the API stream endpoint.
if (contentNodeStreamUrl) {
endChannel = yield* call(createEndChannel, contentNodeStreamUrl)
endChannel = yield* call(
createEndChannel,
contentNodeStreamUrl,
loadTimeoutMs
)
} else {
const { data, signature } = yield* call(
audiusBackendInstance.signGatedContentRequest,
Expand All @@ -204,7 +253,7 @@ export function* watchPlay() {
preview: shouldPreview ? true : undefined
}
)
endChannel = yield* call(createEndChannel, streamUrl)
endChannel = yield* call(createEndChannel, streamUrl, undefined)
}

yield* spawn(actionChannelDispatcher, endChannel)
Expand Down Expand Up @@ -408,7 +457,9 @@ export function* handleAudioErrors() {
const retries = yield* select(getPlaybackRetryCount)
const { shouldPreview } = calculatePlayerBehavior(track, playerBehavior)
const streamObj = shouldPreview ? track?.preview : track?.stream
if (streamObj?.mirrors && streamObj.mirrors.length + 1 > retries) {
const numUrls = 1 + (streamObj?.mirrors?.length ?? 0)
const maxRetries = numUrls * 3
if (streamObj?.url && maxRetries > retries) {
yield* put(
play({
trackId,
Expand Down
Loading