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/common/src/utils/constants.ts b/packages/common/src/utils/constants.ts index 79dd0490131..f348b415236 100644 --- a/packages/common/src/utils/constants.ts +++ b/packages/common/src/utils/constants.ts @@ -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 diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 24a88c3ce1e..aca2614944d 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -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' 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 new file mode 100644 index 00000000000..e4e68f809ea --- /dev/null +++ b/packages/common/src/utils/resolveStreamUrl.ts @@ -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 => { + 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) +} diff --git a/packages/common/src/utils/resolveUrlWithCascadingTimeout.ts b/packages/common/src/utils/resolveUrlWithCascadingTimeout.ts new file mode 100644 index 00000000000..46a6d6bdbe7 --- /dev/null +++ b/packages/common/src/utils/resolveUrlWithCascadingTimeout.ts @@ -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 => { + 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. + * 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 => { + 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 +} 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 diff --git a/packages/web/src/common/store/player/sagas.ts b/packages/web/src/common/store/player/sagas.ts index aed5cae9cbf..5549be6463c 100644 --- a/packages/web/src/common/store/player/sagas.ts +++ b/packages/web/src/common/store/player/sagas.ts @@ -15,6 +15,7 @@ import { queueSelectors } from '@audius/common/store' import { + CASCADING_TIMEOUTS_MS, Genre, actionChannelDispatcher, getTrackPreviewDuration, @@ -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) { const { uid, trackId, playerBehavior, startTime, onEnd, retries } = @@ -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 || @@ -174,7 +218,8 @@ export function* watchPlay() { ) } }, - url + url, + timeoutMs ) return () => {} }) @@ -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, @@ -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) @@ -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, diff --git a/packages/web/src/services/audio-player/AudioPlayer.ts b/packages/web/src/services/audio-player/AudioPlayer.ts index edf80f3e105..6e1e3ca50fc 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 { 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 +47,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 +79,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) => {} @@ -113,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) { @@ -134,6 +140,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 +155,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 +187,15 @@ 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) { + this.audio.removeAttribute('src') + this.audio.src = '' + this.onError(AudioError.AUDIO, 'timeout') + } + }, timeout) this.audio.volume = prevVolume this.audio.onloadedmetadata = () => (this.duration = this.audio.duration) }