From 80ee91d407e49fc41894e9df34162c322316ea13 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 25 Feb 2026 10:43:13 -0800 Subject: [PATCH 1/8] Add 5s pending playback timeout --- packages/common/src/utils/constants.ts | 3 +++ .../src/services/audio-player/AudioPlayer.ts | 20 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/common/src/utils/constants.ts b/packages/common/src/utils/constants.ts index 79dd0490131..25e600bb340 100644 --- a/packages/common/src/utils/constants.ts +++ b/packages/common/src/utils/constants.ts @@ -5,6 +5,9 @@ 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 +export const AUDIO_LOAD_TIMEOUT_MS = 5000 export const TEMPORARY_PASSWORD = 'TemporaryPassword' export const AUDIO_MATCHING_REWARDS_MULTIPLIER = 1 diff --git a/packages/web/src/services/audio-player/AudioPlayer.ts b/packages/web/src/services/audio-player/AudioPlayer.ts index edf80f3e105..11dc4fe7955 100644 --- a/packages/web/src/services/audio-player/AudioPlayer.ts +++ b/packages/web/src/services/audio-player/AudioPlayer.ts @@ -1,5 +1,5 @@ import { playbackRateValueMap, PlaybackRate } from '@audius/common/store' -import { MIN_BUFFERING_DELAY_MS } from '@audius/common/utils' +import { AUDIO_LOAD_TIMEOUT_MS, MIN_BUFFERING_DELAY_MS } from '@audius/common/utils' declare global { interface Window { @@ -44,6 +44,7 @@ export class AudioPlayer { duration: number playbackRate: PlaybackRate bufferingTimeout: ReturnType | null + loadTimeout: ReturnType | null buffering: boolean onBufferingChange: (isBuffering: boolean) => void concatBufferInterval: ReturnType | null @@ -75,6 +76,7 @@ export class AudioPlayer { this.playbackRate = '1x' this.bufferingTimeout = null + this.loadTimeout = null this.buffering = false // Callback fired when buffering status changes this.onBufferingChange = (isBuffering) => {} @@ -134,6 +136,10 @@ export class AudioPlayer { if (this.bufferingTimeout) { clearTimeout(this.bufferingTimeout) } + if (this.loadTimeout) { + clearTimeout(this.loadTimeout) + this.loadTimeout = null + } this.audio = new Audio() @@ -145,6 +151,10 @@ export class AudioPlayer { this.audioCtx = null this.audio.addEventListener('canplay', () => { + if (this.loadTimeout) { + clearTimeout(this.loadTimeout) + this.loadTimeout = null + } if (!this.audioCtx && !IS_SAFARI && !IS_UI_WEBVIEW) { // Set up WebAudio API handles const AudioContext = window.AudioContext || window.webkitAudioContext @@ -173,6 +183,14 @@ export class AudioPlayer { this.audio.preload = 'none' this.audio.crossOrigin = 'anonymous' this.audio.src = mp3Url + this.loadTimeout = setTimeout(() => { + this.loadTimeout = null + if (this.audio.src && this.audio.readyState < 2) { + this.audio.removeAttribute('src') + this.audio.src = '' + this.onError(AudioError.AUDIO, 'timeout') + } + }, AUDIO_LOAD_TIMEOUT_MS) this.audio.volume = prevVolume this.audio.onloadedmetadata = () => (this.duration = this.audio.duration) } From 24a757b01a5668561e8947c8a37590cf5b03610e Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 25 Feb 2026 10:49:01 -0800 Subject: [PATCH 2/8] Fix mobile --- packages/common/src/utils/index.ts | 1 + packages/common/src/utils/resolveStreamUrl.ts | 64 +++++++++++++++++++ .../src/components/audio/AudioPlayer.tsx | 15 ++--- 3 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 packages/common/src/utils/resolveStreamUrl.ts diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 24a88c3ce1e..02151e51105 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -19,6 +19,7 @@ export * from './wallet' export * from './sagaHelpers' export * from './fileUtil' export * from './constants' +export * from './resolveStreamUrl' export * from './stringUtils' export * from './challenges' export * as creativeCommons from './creativeCommons' diff --git a/packages/common/src/utils/resolveStreamUrl.ts b/packages/common/src/utils/resolveStreamUrl.ts new file mode 100644 index 00000000000..7652c0a6784 --- /dev/null +++ b/packages/common/src/utils/resolveStreamUrl.ts @@ -0,0 +1,64 @@ +import { AUDIO_LOAD_TIMEOUT_MS } from './constants' + +type StreamObject = { url?: string; mirrors?: string[] } + +const tryUrl = async (url: string): Promise => { + try { + const controller = new AbortController() + const timeoutId = setTimeout( + () => controller.abort(), + AUDIO_LOAD_TIMEOUT_MS + ) + const response = await fetch(url, { + method: 'HEAD', + signal: controller.signal + }) + clearTimeout(timeoutId) + return response.ok + } catch { + return false + } +} + +const tryUrls = async (urls: string[]): Promise => { + for (const url of urls) { + if (await tryUrl(url)) { + return url + } + } + return urls[0] ?? '' +} + +/** + * Resolves a working stream URL from a stream object, trying the primary URL + * and mirrors with a 5s timeout per attempt. Returns the first URL that + * responds successfully, or the primary URL as fallback. + * + * @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 => { + 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 + } + } + } + + const urlsToAttempt = urlsToTry.slice(skipCount) + const workingUrl = await tryUrls(urlsToAttempt) + return workingUrl || urlsToAttempt[0] || null +} diff --git a/packages/mobile/src/components/audio/AudioPlayer.tsx b/packages/mobile/src/components/audio/AudioPlayer.tsx index 515c3284b69..462f343084c 100644 --- a/packages/mobile/src/components/audio/AudioPlayer.tsx +++ b/packages/mobile/src/components/audio/AudioPlayer.tsx @@ -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, @@ -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 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 From 45ca1ebcc214f4e24378f793d81e07d9efdaaf9a Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 25 Feb 2026 10:50:34 -0800 Subject: [PATCH 3/8] Fix lint --- packages/web/src/services/audio-player/AudioPlayer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/web/src/services/audio-player/AudioPlayer.ts b/packages/web/src/services/audio-player/AudioPlayer.ts index 11dc4fe7955..b45cf8d79a7 100644 --- a/packages/web/src/services/audio-player/AudioPlayer.ts +++ b/packages/web/src/services/audio-player/AudioPlayer.ts @@ -1,5 +1,8 @@ import { playbackRateValueMap, PlaybackRate } from '@audius/common/store' -import { AUDIO_LOAD_TIMEOUT_MS, MIN_BUFFERING_DELAY_MS } from '@audius/common/utils' +import { + AUDIO_LOAD_TIMEOUT_MS, + MIN_BUFFERING_DELAY_MS +} from '@audius/common/utils' declare global { interface Window { From 62fbf4473e6a29c8b7abe468b7a56b536e2dc13e Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 25 Feb 2026 10:55:42 -0800 Subject: [PATCH 4/8] Add backoff starting with 2 seconds --- packages/common/src/services/audio-player.ts | 7 ++++++- packages/common/src/utils/constants.ts | 17 +++++++++++++++-- packages/common/src/utils/resolveStreamUrl.ts | 16 +++++++--------- packages/web/src/common/store/player/sagas.ts | 17 +++++++++++++---- .../src/services/audio-player/AudioPlayer.ts | 8 +++++--- 5 files changed, 46 insertions(+), 19 deletions(-) diff --git a/packages/common/src/services/audio-player.ts b/packages/common/src/services/audio-player.ts index 1370231d363..d829fb09eb2 100644 --- a/packages/common/src/services/audio-player.ts +++ b/packages/common/src/services/audio-player.ts @@ -15,7 +15,12 @@ export enum AudioError { export type AudioPlayer = { audio: HTMLAudioElement - load: (duration: number, onEnd: () => void, mp3Url: Nullable) => void + load: ( + duration: number, + onEnd: () => void, + mp3Url: Nullable, + timeoutMs?: number + ) => void play: () => void pause: () => void stop: () => void diff --git a/packages/common/src/utils/constants.ts b/packages/common/src/utils/constants.ts index 25e600bb340..0bddae46ae0 100644 --- a/packages/common/src/utils/constants.ts +++ b/packages/common/src/utils/constants.ts @@ -6,8 +6,21 @@ export const MESSAGE_GROUP_THRESHOLD_MINUTES = 2 // 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 -export const AUDIO_LOAD_TIMEOUT_MS = 5000 +// Maximum time to wait for an audio request to start loading before trying next mirror (base, backs off on retries) +export const AUDIO_LOAD_TIMEOUT_MS = 2000 +export const AUDIO_LOAD_TIMEOUT_BACKOFF_MULTIPLIER = 1.5 +export const AUDIO_LOAD_TIMEOUT_MAX_MS = 30000 + +/** + * Returns timeout in ms for audio load attempts, with exponential backoff on retries. + */ +export const getAudioLoadTimeoutMs = (retries: number): number => + Math.min( + AUDIO_LOAD_TIMEOUT_MS * + Math.pow(AUDIO_LOAD_TIMEOUT_BACKOFF_MULTIPLIER, retries), + AUDIO_LOAD_TIMEOUT_MAX_MS + ) + export const TEMPORARY_PASSWORD = 'TemporaryPassword' export const AUDIO_MATCHING_REWARDS_MULTIPLIER = 1 diff --git a/packages/common/src/utils/resolveStreamUrl.ts b/packages/common/src/utils/resolveStreamUrl.ts index 7652c0a6784..ccd2efb49ff 100644 --- a/packages/common/src/utils/resolveStreamUrl.ts +++ b/packages/common/src/utils/resolveStreamUrl.ts @@ -1,14 +1,11 @@ -import { AUDIO_LOAD_TIMEOUT_MS } from './constants' +import { getAudioLoadTimeoutMs } from './constants' type StreamObject = { url?: string; mirrors?: string[] } -const tryUrl = async (url: string): Promise => { +const tryUrl = async (url: string, timeoutMs: number): Promise => { try { const controller = new AbortController() - const timeoutId = setTimeout( - () => controller.abort(), - AUDIO_LOAD_TIMEOUT_MS - ) + const timeoutId = setTimeout(() => controller.abort(), timeoutMs) const response = await fetch(url, { method: 'HEAD', signal: controller.signal @@ -20,9 +17,10 @@ const tryUrl = async (url: string): Promise => { } } -const tryUrls = async (urls: string[]): Promise => { +const tryUrls = async (urls: string[], retryCount: number): Promise => { + const timeoutMs = getAudioLoadTimeoutMs(retryCount) for (const url of urls) { - if (await tryUrl(url)) { + if (await tryUrl(url, timeoutMs)) { return url } } @@ -59,6 +57,6 @@ export const resolveStreamUrl = async ( } const urlsToAttempt = urlsToTry.slice(skipCount) - const workingUrl = await tryUrls(urlsToAttempt) + const workingUrl = await tryUrls(urlsToAttempt, skipCount) return workingUrl || urlsToAttempt[0] || null } diff --git a/packages/web/src/common/store/player/sagas.ts b/packages/web/src/common/store/player/sagas.ts index aed5cae9cbf..830ed61cc97 100644 --- a/packages/web/src/common/store/player/sagas.ts +++ b/packages/web/src/common/store/player/sagas.ts @@ -17,6 +17,7 @@ import { import { Genre, actionChannelDispatcher, + getAudioLoadTimeoutMs, getTrackPreviewDuration, Nullable } from '@audius/common/utils' @@ -146,7 +147,10 @@ export function* watchPlay() { const isLongFormContent = track.genre === Genre.Podcasts || track.genre === Genre.Audiobooks - const createEndChannel = async (url: string) => { + const retryCount = retries ?? 0 + const loadTimeoutMs = getAudioLoadTimeoutMs(retryCount) + + const createEndChannel = async (url: string, timeoutMs: number) => { const endChannel = eventChannel((emitter) => { audioPlayer.load( trackDuration || @@ -174,7 +178,8 @@ export function* watchPlay() { ) } }, - url + url, + timeoutMs ) return () => {} }) @@ -185,7 +190,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, @@ -204,7 +213,7 @@ export function* watchPlay() { preview: shouldPreview ? true : undefined } ) - endChannel = yield* call(createEndChannel, streamUrl) + endChannel = yield* call(createEndChannel, streamUrl, loadTimeoutMs) } yield* spawn(actionChannelDispatcher, endChannel) diff --git a/packages/web/src/services/audio-player/AudioPlayer.ts b/packages/web/src/services/audio-player/AudioPlayer.ts index b45cf8d79a7..c73f71df8b9 100644 --- a/packages/web/src/services/audio-player/AudioPlayer.ts +++ b/packages/web/src/services/audio-player/AudioPlayer.ts @@ -1,6 +1,6 @@ import { playbackRateValueMap, PlaybackRate } from '@audius/common/store' import { - AUDIO_LOAD_TIMEOUT_MS, + getAudioLoadTimeoutMs, MIN_BUFFERING_DELAY_MS } from '@audius/common/utils' @@ -118,7 +118,8 @@ export class AudioPlayer { load = ( duration: number, onEnd: () => void, - mp3Url: string | null = null + mp3Url: string | null = null, + timeoutMs?: number ) => { this.onEnd = onEnd if (mp3Url) { @@ -186,6 +187,7 @@ export class AudioPlayer { this.audio.preload = 'none' this.audio.crossOrigin = 'anonymous' this.audio.src = mp3Url + const loadTimeoutMs = timeoutMs ?? getAudioLoadTimeoutMs(0) this.loadTimeout = setTimeout(() => { this.loadTimeout = null if (this.audio.src && this.audio.readyState < 2) { @@ -193,7 +195,7 @@ export class AudioPlayer { this.audio.src = '' this.onError(AudioError.AUDIO, 'timeout') } - }, AUDIO_LOAD_TIMEOUT_MS) + }, loadTimeoutMs) this.audio.volume = prevVolume this.audio.onloadedmetadata = () => (this.duration = this.audio.duration) } From 67fe00edcaeef8dd5dd1e65afe83aca7ff74ec6d Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 25 Feb 2026 12:30:59 -0800 Subject: [PATCH 5/8] Revert back to simpler approach --- packages/common/src/services/audio-player.ts | 7 +------ packages/common/src/utils/constants.ts | 17 ++--------------- packages/common/src/utils/resolveStreamUrl.ts | 16 +++++++++------- packages/web/src/common/store/player/sagas.ts | 19 +++++-------------- .../src/services/audio-player/AudioPlayer.ts | 8 +++----- 5 files changed, 20 insertions(+), 47 deletions(-) diff --git a/packages/common/src/services/audio-player.ts b/packages/common/src/services/audio-player.ts index d829fb09eb2..1370231d363 100644 --- a/packages/common/src/services/audio-player.ts +++ b/packages/common/src/services/audio-player.ts @@ -15,12 +15,7 @@ export enum AudioError { export type AudioPlayer = { audio: HTMLAudioElement - load: ( - duration: number, - onEnd: () => void, - mp3Url: Nullable, - timeoutMs?: number - ) => void + load: (duration: number, onEnd: () => void, mp3Url: Nullable) => void play: () => void pause: () => void stop: () => void diff --git a/packages/common/src/utils/constants.ts b/packages/common/src/utils/constants.ts index 0bddae46ae0..25e600bb340 100644 --- a/packages/common/src/utils/constants.ts +++ b/packages/common/src/utils/constants.ts @@ -6,21 +6,8 @@ export const MESSAGE_GROUP_THRESHOLD_MINUTES = 2 // 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 (base, backs off on retries) -export const AUDIO_LOAD_TIMEOUT_MS = 2000 -export const AUDIO_LOAD_TIMEOUT_BACKOFF_MULTIPLIER = 1.5 -export const AUDIO_LOAD_TIMEOUT_MAX_MS = 30000 - -/** - * Returns timeout in ms for audio load attempts, with exponential backoff on retries. - */ -export const getAudioLoadTimeoutMs = (retries: number): number => - Math.min( - AUDIO_LOAD_TIMEOUT_MS * - Math.pow(AUDIO_LOAD_TIMEOUT_BACKOFF_MULTIPLIER, retries), - AUDIO_LOAD_TIMEOUT_MAX_MS - ) - +// Maximum time to wait for an audio request to start loading before trying next mirror +export const AUDIO_LOAD_TIMEOUT_MS = 5000 export const TEMPORARY_PASSWORD = 'TemporaryPassword' export const AUDIO_MATCHING_REWARDS_MULTIPLIER = 1 diff --git a/packages/common/src/utils/resolveStreamUrl.ts b/packages/common/src/utils/resolveStreamUrl.ts index ccd2efb49ff..7652c0a6784 100644 --- a/packages/common/src/utils/resolveStreamUrl.ts +++ b/packages/common/src/utils/resolveStreamUrl.ts @@ -1,11 +1,14 @@ -import { getAudioLoadTimeoutMs } from './constants' +import { AUDIO_LOAD_TIMEOUT_MS } from './constants' type StreamObject = { url?: string; mirrors?: string[] } -const tryUrl = async (url: string, timeoutMs: number): Promise => { +const tryUrl = async (url: string): Promise => { try { const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeoutMs) + const timeoutId = setTimeout( + () => controller.abort(), + AUDIO_LOAD_TIMEOUT_MS + ) const response = await fetch(url, { method: 'HEAD', signal: controller.signal @@ -17,10 +20,9 @@ const tryUrl = async (url: string, timeoutMs: number): Promise => { } } -const tryUrls = async (urls: string[], retryCount: number): Promise => { - const timeoutMs = getAudioLoadTimeoutMs(retryCount) +const tryUrls = async (urls: string[]): Promise => { for (const url of urls) { - if (await tryUrl(url, timeoutMs)) { + if (await tryUrl(url)) { return url } } @@ -57,6 +59,6 @@ export const resolveStreamUrl = async ( } const urlsToAttempt = urlsToTry.slice(skipCount) - const workingUrl = await tryUrls(urlsToAttempt, skipCount) + const workingUrl = await tryUrls(urlsToAttempt) return workingUrl || urlsToAttempt[0] || null } diff --git a/packages/web/src/common/store/player/sagas.ts b/packages/web/src/common/store/player/sagas.ts index 830ed61cc97..2b8a385023a 100644 --- a/packages/web/src/common/store/player/sagas.ts +++ b/packages/web/src/common/store/player/sagas.ts @@ -17,7 +17,6 @@ import { import { Genre, actionChannelDispatcher, - getAudioLoadTimeoutMs, getTrackPreviewDuration, Nullable } from '@audius/common/utils' @@ -86,7 +85,7 @@ export const getMirrorStreamUrl = ( return streamUrl.toString() } } - return streamObj?.url ?? null + return streamObj?.url ?? nul } export function* watchPlay() { @@ -147,10 +146,7 @@ export function* watchPlay() { const isLongFormContent = track.genre === Genre.Podcasts || track.genre === Genre.Audiobooks - const retryCount = retries ?? 0 - const loadTimeoutMs = getAudioLoadTimeoutMs(retryCount) - - const createEndChannel = async (url: string, timeoutMs: number) => { + const createEndChannel = async (url: string) => { const endChannel = eventChannel((emitter) => { audioPlayer.load( trackDuration || @@ -178,8 +174,7 @@ export function* watchPlay() { ) } }, - url, - timeoutMs + url ) return () => {} }) @@ -190,11 +185,7 @@ 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, - loadTimeoutMs - ) + endChannel = yield* call(createEndChannel, contentNodeStreamUrl) } else { const { data, signature } = yield* call( audiusBackendInstance.signGatedContentRequest, @@ -213,7 +204,7 @@ export function* watchPlay() { preview: shouldPreview ? true : undefined } ) - endChannel = yield* call(createEndChannel, streamUrl, loadTimeoutMs) + endChannel = yield* call(createEndChannel, streamUrl) } yield* spawn(actionChannelDispatcher, endChannel) diff --git a/packages/web/src/services/audio-player/AudioPlayer.ts b/packages/web/src/services/audio-player/AudioPlayer.ts index c73f71df8b9..b45cf8d79a7 100644 --- a/packages/web/src/services/audio-player/AudioPlayer.ts +++ b/packages/web/src/services/audio-player/AudioPlayer.ts @@ -1,6 +1,6 @@ import { playbackRateValueMap, PlaybackRate } from '@audius/common/store' import { - getAudioLoadTimeoutMs, + AUDIO_LOAD_TIMEOUT_MS, MIN_BUFFERING_DELAY_MS } from '@audius/common/utils' @@ -118,8 +118,7 @@ export class AudioPlayer { load = ( duration: number, onEnd: () => void, - mp3Url: string | null = null, - timeoutMs?: number + mp3Url: string | null = null ) => { this.onEnd = onEnd if (mp3Url) { @@ -187,7 +186,6 @@ export class AudioPlayer { this.audio.preload = 'none' this.audio.crossOrigin = 'anonymous' this.audio.src = mp3Url - const loadTimeoutMs = timeoutMs ?? getAudioLoadTimeoutMs(0) this.loadTimeout = setTimeout(() => { this.loadTimeout = null if (this.audio.src && this.audio.readyState < 2) { @@ -195,7 +193,7 @@ export class AudioPlayer { this.audio.src = '' this.onError(AudioError.AUDIO, 'timeout') } - }, loadTimeoutMs) + }, AUDIO_LOAD_TIMEOUT_MS) this.audio.volume = prevVolume this.audio.onloadedmetadata = () => (this.duration = this.audio.duration) } From 9ba6ce00eb35e336222d4ac3c8cc49501a20b8c0 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 25 Feb 2026 12:36:17 -0800 Subject: [PATCH 6/8] Update resolveStreamUrl to use resolveUrlWithCascadingTimeout --- packages/common/src/utils/constants.ts | 3 +- packages/common/src/utils/index.ts | 1 + packages/common/src/utils/resolveImageUrl.ts | 32 ++------ packages/common/src/utils/resolveStreamUrl.ts | 38 +-------- .../utils/resolveUrlWithCascadingTimeout.ts | 80 +++++++++++++++++++ packages/web/src/common/store/player/sagas.ts | 14 ++-- 6 files changed, 99 insertions(+), 69 deletions(-) create mode 100644 packages/common/src/utils/resolveUrlWithCascadingTimeout.ts diff --git a/packages/common/src/utils/constants.ts b/packages/common/src/utils/constants.ts index 25e600bb340..f348b415236 100644 --- a/packages/common/src/utils/constants.ts +++ b/packages/common/src/utils/constants.ts @@ -7,7 +7,8 @@ export const MESSAGE_GROUP_THRESHOLD_MINUTES = 2 export const MIN_BUFFERING_DELAY_MS = 1000 // Maximum time to wait for an audio request to start loading before trying next mirror -export const AUDIO_LOAD_TIMEOUT_MS = 5000 +// 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 diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 02151e51105..aca2614944d 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -20,6 +20,7 @@ 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' diff --git a/packages/common/src/utils/resolveImageUrl.ts b/packages/common/src/utils/resolveImageUrl.ts index b78803ae5ed..de197edc7e2 100644 --- a/packages/common/src/utils/resolveImageUrl.ts +++ b/packages/common/src/utils/resolveImageUrl.ts @@ -1,37 +1,15 @@ import { SquareSizes, WidthSizes } from '~/models/ImageSizes' import { Maybe } from '~/utils/typeUtils' +import { resolveUrlWithCascadingTimeout } from './resolveUrlWithCascadingTimeout' + type Artwork = { [key in T]?: string } & { mirrors?: string[] | undefined } -const tryUrl = async (url: string): Promise => { - 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 => { - 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 @@ -72,6 +50,6 @@ export const resolveImageUrl = async < } } - const workingUrl = await tryUrls(urlsToTry) + const workingUrl = await resolveUrlWithCascadingTimeout(urlsToTry) return workingUrl || defaultImage } diff --git a/packages/common/src/utils/resolveStreamUrl.ts b/packages/common/src/utils/resolveStreamUrl.ts index 7652c0a6784..e4e68f809ea 100644 --- a/packages/common/src/utils/resolveStreamUrl.ts +++ b/packages/common/src/utils/resolveStreamUrl.ts @@ -1,38 +1,10 @@ -import { AUDIO_LOAD_TIMEOUT_MS } from './constants' +import { resolveUrlWithCascadingTimeout } from './resolveUrlWithCascadingTimeout' type StreamObject = { url?: string; mirrors?: string[] } -const tryUrl = async (url: string): Promise => { - try { - const controller = new AbortController() - const timeoutId = setTimeout( - () => controller.abort(), - AUDIO_LOAD_TIMEOUT_MS - ) - const response = await fetch(url, { - method: 'HEAD', - signal: controller.signal - }) - clearTimeout(timeoutId) - return response.ok - } catch { - return false - } -} - -const tryUrls = async (urls: string[]): Promise => { - for (const url of urls) { - if (await tryUrl(url)) { - return url - } - } - return urls[0] ?? '' -} - /** - * Resolves a working stream URL from a stream object, trying the primary URL - * and mirrors with a 5s timeout per attempt. Returns the first URL that - * responds successfully, or the primary URL as fallback. + * 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) @@ -58,7 +30,5 @@ export const resolveStreamUrl = async ( } } - const urlsToAttempt = urlsToTry.slice(skipCount) - const workingUrl = await tryUrls(urlsToAttempt) - return workingUrl || urlsToAttempt[0] || null + return resolveUrlWithCascadingTimeout(urlsToTry, skipCount) } diff --git a/packages/common/src/utils/resolveUrlWithCascadingTimeout.ts b/packages/common/src/utils/resolveUrlWithCascadingTimeout.ts new file mode 100644 index 00000000000..008affcc067 --- /dev/null +++ b/packages/common/src/utils/resolveUrlWithCascadingTimeout.ts @@ -0,0 +1,80 @@ +/** + * Cascading timeout phases for URL resolution: + * 1. Try primary with 2s + * 2. If fail, try all mirrors with 2s each + * 3. If all fail, try all mirrors with 5s each + * 4. If all fail, try all mirrors with 30s each + */ +export const CASCADING_TIMEOUTS_MS = [2000, 5000, 30000] as const + +const tryUrlWithTimeout = async ( + url: string, + timeoutMs: number +): Promise => { + 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 => { + for (const url of urls) { + if (await tryUrlWithTimeout(url, timeoutMs)) { + return url + } + } + return null +} + +/** + * Resolves a working URL using cascading timeouts: + * - Try primary with 2s + * - If fail, try all mirrors with 2s each + * - If all fail, try all mirrors with 5s each + * - If all fail, try all mirrors with 30s each + * + * @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 => { + const urlsToTry = urls.slice(skipCount) + if (urlsToTry.length === 0) { + return null + } + + const [primary, ...mirrors] = urlsToTry + const primaryFallback = primary ?? null + + // Phase 1: Try primary with 2s + if (primary && (await tryUrlWithTimeout(primary, CASCADING_TIMEOUTS_MS[0]))) { + return primary + } + + if (mirrors.length === 0) { + return primaryFallback + } + + // Phases 2-4: Try all mirrors with 2s, 5s, then 30s + for (const timeoutMs of CASCADING_TIMEOUTS_MS) { + const workingUrl = await tryUrlsWithTimeout(mirrors, timeoutMs) + if (workingUrl) { + return workingUrl + } + } + + return primaryFallback +} diff --git a/packages/web/src/common/store/player/sagas.ts b/packages/web/src/common/store/player/sagas.ts index 2b8a385023a..cea63b518b8 100644 --- a/packages/web/src/common/store/player/sagas.ts +++ b/packages/web/src/common/store/player/sagas.ts @@ -18,7 +18,8 @@ import { Genre, actionChannelDispatcher, getTrackPreviewDuration, - Nullable + Nullable, + resolveStreamUrl } from '@audius/common/utils' import { Id, OptionalId } from '@audius/sdk' import { EventChannel, eventChannel } from 'redux-saga' @@ -85,7 +86,7 @@ export const getMirrorStreamUrl = ( return streamUrl.toString() } } - return streamObj?.url ?? nul + return streamObj?.url ?? null } export function* watchPlay() { @@ -137,11 +138,10 @@ export function* watchPlay() { trackDuration = getTrackPreviewDuration(track) } - const contentNodeStreamUrl = getMirrorStreamUrl( - track, - shouldPreview, - retries ?? 0 - ) + const streamObj = shouldPreview ? track.preview : track.stream + const contentNodeStreamUrl = streamObj?.url + ? yield* call(resolveStreamUrl, streamObj, retries ?? 0) + : null const isLongFormContent = track.genre === Genre.Podcasts || track.genre === Genre.Audiobooks From e4c64e9383f355d9875651dbe67d0c337f46f81c Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 25 Feb 2026 13:12:10 -0800 Subject: [PATCH 7/8] Address comments --- packages/common/src/services/audio-player.ts | 7 +- packages/web/src/common/store/player/sagas.ts | 78 ++++++++++++++++--- .../src/services/audio-player/AudioPlayer.ts | 6 +- 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/packages/common/src/services/audio-player.ts b/packages/common/src/services/audio-player.ts index 1370231d363..be6c5b987db 100644 --- a/packages/common/src/services/audio-player.ts +++ b/packages/common/src/services/audio-player.ts @@ -15,7 +15,12 @@ export enum AudioError { export type AudioPlayer = { audio: HTMLAudioElement - load: (duration: number, onEnd: () => void, mp3Url: Nullable) => void + load: ( + duration: number, + onEnd: () => void, + mp3Url: Nullable, + loadTimeoutMs?: number + ) => void play: () => void pause: () => void stop: () => void diff --git a/packages/web/src/common/store/player/sagas.ts b/packages/web/src/common/store/player/sagas.ts index cea63b518b8..8669721e9f2 100644 --- a/packages/web/src/common/store/player/sagas.ts +++ b/packages/web/src/common/store/player/sagas.ts @@ -15,11 +15,11 @@ import { queueSelectors } from '@audius/common/store' import { + CASCADING_TIMEOUTS_MS, Genre, actionChannelDispatcher, getTrackPreviewDuration, - Nullable, - resolveStreamUrl + Nullable } from '@audius/common/utils' import { Id, OptionalId } from '@audius/sdk' import { EventChannel, eventChannel } from 'redux-saga' @@ -89,6 +89,56 @@ export const getMirrorStreamUrl = ( return streamObj?.url ?? null } +/** + * Returns the stream URL and load timeout for the given retry index. + * Uses the cascading pattern: primary (2s) → mirrors (2s) → mirrors (5s) → mirrors (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 numMirrors = urls.length - 1 + const maxRetries = 1 + numMirrors * 3 + + if (numMirrors === 0) { + return { url: urls[0], timeoutMs: CASCADING_TIMEOUTS_MS[0] } + } + if (retries >= maxRetries) { + return { url: urls[0], timeoutMs: defaultTimeout } + } + + if (retries === 0) { + return { url: urls[0], timeoutMs: CASCADING_TIMEOUTS_MS[0] } + } + if (retries <= numMirrors) { + return { url: urls[retries], timeoutMs: CASCADING_TIMEOUTS_MS[0] } + } + if (retries <= numMirrors * 2) { + const mirrorIndex = 1 + (retries - numMirrors - 1) + return { url: urls[mirrorIndex], timeoutMs: CASCADING_TIMEOUTS_MS[1] } + } + const mirrorIndex = 1 + ((retries - numMirrors * 2 - 1) % numMirrors) + return { url: urls[mirrorIndex], timeoutMs: CASCADING_TIMEOUTS_MS[2] } +} + export function* watchPlay() { yield* takeLatest(play.type, function* (action: ReturnType) { const { uid, trackId, playerBehavior, startTime, onEnd, retries } = @@ -139,14 +189,15 @@ export function* watchPlay() { } const streamObj = shouldPreview ? track.preview : track.stream - const contentNodeStreamUrl = streamObj?.url - ? yield* call(resolveStreamUrl, streamObj, retries ?? 0) - : null + 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 || @@ -174,7 +225,8 @@ export function* watchPlay() { ) } }, - url + url, + timeoutMs ) return () => {} }) @@ -185,7 +237,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, @@ -204,7 +260,7 @@ export function* watchPlay() { preview: shouldPreview ? true : undefined } ) - endChannel = yield* call(createEndChannel, streamUrl) + endChannel = yield* call(createEndChannel, streamUrl, undefined) } yield* spawn(actionChannelDispatcher, endChannel) @@ -408,7 +464,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 numMirrors = streamObj?.mirrors?.length ?? 0 + const maxRetries = 1 + numMirrors * 3 + if (streamObj?.url && maxRetries > retries) { yield* put( play({ trackId, diff --git a/packages/web/src/services/audio-player/AudioPlayer.ts b/packages/web/src/services/audio-player/AudioPlayer.ts index b45cf8d79a7..6e1e3ca50fc 100644 --- a/packages/web/src/services/audio-player/AudioPlayer.ts +++ b/packages/web/src/services/audio-player/AudioPlayer.ts @@ -118,7 +118,8 @@ export class AudioPlayer { load = ( duration: number, onEnd: () => void, - mp3Url: string | null = null + mp3Url: string | null = null, + loadTimeoutMs?: number ) => { this.onEnd = onEnd if (mp3Url) { @@ -186,6 +187,7 @@ export class AudioPlayer { this.audio.preload = 'none' this.audio.crossOrigin = 'anonymous' this.audio.src = mp3Url + const timeout = loadTimeoutMs ?? AUDIO_LOAD_TIMEOUT_MS this.loadTimeout = setTimeout(() => { this.loadTimeout = null if (this.audio.src && this.audio.readyState < 2) { @@ -193,7 +195,7 @@ export class AudioPlayer { this.audio.src = '' this.onError(AudioError.AUDIO, 'timeout') } - }, AUDIO_LOAD_TIMEOUT_MS) + }, timeout) this.audio.volume = prevVolume this.audio.onloadedmetadata = () => (this.duration = this.audio.duration) } From b0d3e1d2333aaffe2f6da37af114a7224a0e0c86 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 25 Feb 2026 13:22:43 -0800 Subject: [PATCH 8/8] include primary in retries --- .../utils/resolveUrlWithCascadingTimeout.ts | 35 +++++++------------ packages/web/src/common/store/player/sagas.ts | 33 +++++++---------- 2 files changed, 25 insertions(+), 43 deletions(-) diff --git a/packages/common/src/utils/resolveUrlWithCascadingTimeout.ts b/packages/common/src/utils/resolveUrlWithCascadingTimeout.ts index 008affcc067..46a6d6bdbe7 100644 --- a/packages/common/src/utils/resolveUrlWithCascadingTimeout.ts +++ b/packages/common/src/utils/resolveUrlWithCascadingTimeout.ts @@ -1,9 +1,9 @@ /** - * Cascading timeout phases for URL resolution: - * 1. Try primary with 2s - * 2. If fail, try all mirrors with 2s each - * 3. If all fail, try all mirrors with 5s each - * 4. If all fail, try all mirrors with 30s each + * 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 @@ -38,11 +38,11 @@ const tryUrlsWithTimeout = async ( } /** - * Resolves a working URL using cascading timeouts: - * - Try primary with 2s - * - If fail, try all mirrors with 2s each - * - If all fail, try all mirrors with 5s each - * - If all fail, try all mirrors with 30s each + * 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) @@ -56,21 +56,10 @@ export const resolveUrlWithCascadingTimeout = async ( return null } - const [primary, ...mirrors] = urlsToTry - const primaryFallback = primary ?? null + const primaryFallback = urlsToTry[0] ?? null - // Phase 1: Try primary with 2s - if (primary && (await tryUrlWithTimeout(primary, CASCADING_TIMEOUTS_MS[0]))) { - return primary - } - - if (mirrors.length === 0) { - return primaryFallback - } - - // Phases 2-4: Try all mirrors with 2s, 5s, then 30s for (const timeoutMs of CASCADING_TIMEOUTS_MS) { - const workingUrl = await tryUrlsWithTimeout(mirrors, timeoutMs) + const workingUrl = await tryUrlsWithTimeout(urlsToTry, timeoutMs) if (workingUrl) { return workingUrl } diff --git a/packages/web/src/common/store/player/sagas.ts b/packages/web/src/common/store/player/sagas.ts index 8669721e9f2..5549be6463c 100644 --- a/packages/web/src/common/store/player/sagas.ts +++ b/packages/web/src/common/store/player/sagas.ts @@ -91,7 +91,10 @@ export const getMirrorStreamUrl = ( /** * Returns the stream URL and load timeout for the given retry index. - * Uses the cascading pattern: primary (2s) → mirrors (2s) → mirrors (5s) → mirrors (30s). + * 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 = ( @@ -115,28 +118,18 @@ const getStreamUrlAndTimeout = ( } } - const numMirrors = urls.length - 1 - const maxRetries = 1 + numMirrors * 3 + const urlsPerPhase = urls.length + const maxRetries = urlsPerPhase * 3 - if (numMirrors === 0) { - return { url: urls[0], timeoutMs: CASCADING_TIMEOUTS_MS[0] } - } if (retries >= maxRetries) { return { url: urls[0], timeoutMs: defaultTimeout } } - if (retries === 0) { - return { url: urls[0], timeoutMs: CASCADING_TIMEOUTS_MS[0] } - } - if (retries <= numMirrors) { - return { url: urls[retries], timeoutMs: CASCADING_TIMEOUTS_MS[0] } - } - if (retries <= numMirrors * 2) { - const mirrorIndex = 1 + (retries - numMirrors - 1) - return { url: urls[mirrorIndex], timeoutMs: CASCADING_TIMEOUTS_MS[1] } - } - const mirrorIndex = 1 + ((retries - numMirrors * 2 - 1) % numMirrors) - return { url: urls[mirrorIndex], timeoutMs: CASCADING_TIMEOUTS_MS[2] } + const phase = Math.floor(retries / urlsPerPhase) + const urlIndex = retries % urlsPerPhase + const timeoutMs = CASCADING_TIMEOUTS_MS[phase] + + return { url: urls[urlIndex], timeoutMs } } export function* watchPlay() { @@ -464,8 +457,8 @@ export function* handleAudioErrors() { const retries = yield* select(getPlaybackRetryCount) const { shouldPreview } = calculatePlayerBehavior(track, playerBehavior) const streamObj = shouldPreview ? track?.preview : track?.stream - const numMirrors = streamObj?.mirrors?.length ?? 0 - const maxRetries = 1 + numMirrors * 3 + const numUrls = 1 + (streamObj?.mirrors?.length ?? 0) + const maxRetries = numUrls * 3 if (streamObj?.url && maxRetries > retries) { yield* put( play({