From 7f05988274b7276638af389ece1d2a270dc64ff3 Mon Sep 17 00:00:00 2001 From: Gav Richards Date: Thu, 20 Aug 2026 16:54:08 +0100 Subject: [PATCH] fix: detectHttpByteRanges should still try a Range GET after an inconclusive HEAD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectHttpByteRanges()'s docstring says it does 'HEAD, then Range probe', but the code only tried the Range GET when the HEAD request itself threw. Any HEAD response that merely succeeded without an Accept-Ranges: bytes header was treated as decisive proof of no range support, even though the intended fallback probe was never attempted. This misdetects real servers: confirmed against a live public Icecast stream (SomaFM) that returns 200 OK on HEAD with no Accept-Ranges header, but answers a Range GET with a genuine 206 Partial Content. Sending that case to detectHttpByteRanges's fallback (rather than returning false immediately) lets loadRemoteHttpSource correctly hand the URL to native code instead of attempting to buffer an indefinite live stream into memory via response.arrayBuffer(), which never resolves. Fix: only short-circuit to true when Accept-Ranges: bytes is confirmed; any other HEAD outcome (ok-but-inconclusive, non-ok, or thrown) now falls through to the Range GET probe, matching the function's own documented behavior. Verified against the real repro: before this change, loading https://ice1.somafm.com/groovesalad-128-mp3 through loadRemoteHttpSource() (FFmpeg enabled) never resolved; after, it resolves in <1s. The FFmpeg- disabled path (which skips byte-range detection by design) still hangs on the same URL — that's a separate, deeper gap tracked for a follow-up PR (an explicit stream/type hint on AudioURISource), not something this fix is meant to address. Co-Authored-By: Claude Sonnet 5 --- .../src/utils/remoteHttpSource.ts | 18 +++--- .../tests/remote-http-source.test.ts | 56 +++++++++++++++++++ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/packages/react-native-audio-api/src/utils/remoteHttpSource.ts b/packages/react-native-audio-api/src/utils/remoteHttpSource.ts index b46a1b56a..33a036502 100644 --- a/packages/react-native-audio-api/src/utils/remoteHttpSource.ts +++ b/packages/react-native-audio-api/src/utils/remoteHttpSource.ts @@ -15,16 +15,16 @@ async function detectHttpByteRanges( ): Promise { try { const headResponse = await fetch(url, { method: 'HEAD', headers }); - if (headResponse.ok) { - const acceptRanges = headResponse.headers - .get('Accept-Ranges') - ?.toLowerCase(); - if (acceptRanges === 'bytes') { - return true; - } - - return false; + const acceptRanges = headResponse.headers + .get('Accept-Ranges') + ?.toLowerCase(); + if (acceptRanges === 'bytes') { + return true; } + // HEAD succeeded but didn't confirm ranges (or wasn't ok) — some servers + // (e.g. several Icecast/Shoutcast configurations) omit Accept-Ranges from + // HEAD while still honoring a Range GET, so don't conclude "unsupported" + // without trying that too. } catch { // HEAD may be blocked or unsupported — fall through to a Range GET probe. } diff --git a/packages/react-native-audio-api/tests/remote-http-source.test.ts b/packages/react-native-audio-api/tests/remote-http-source.test.ts index acfe5ffca..1a0651edf 100644 --- a/packages/react-native-audio-api/tests/remote-http-source.test.ts +++ b/packages/react-native-audio-api/tests/remote-http-source.test.ts @@ -54,6 +54,62 @@ describe('loadRemoteHttpSource', () => { }); }); + it('falls through to a Range GET probe when HEAD succeeds without confirming Accept-Ranges', async () => { + isFfmpegEnabledMock.mockReturnValue(true); + fetchMock.mockImplementation( + async (_url: string, options?: { method?: string }) => { + if (options?.method === 'HEAD') { + // Some Icecast/Shoutcast servers omit Accept-Ranges from HEAD even + // though they honor a Range GET — confirmed against a real public + // stream (SomaFM) during the aiirmobile investigation. + return { ok: true, headers: { get: () => null } }; + } + return { ok: true, status: 206 }; + } + ); + + await expect( + loadRemoteHttpSource('https://example.com/stream.mp3') + ).resolves.toBe('https://example.com/stream.mp3'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://example.com/stream.mp3', + { method: 'HEAD', headers: undefined } + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://example.com/stream.mp3', + { headers: { Range: 'bytes=0-0' } } + ); + }); + + it('downloads when neither HEAD nor a Range GET confirm byte-range support', async () => { + isFfmpegEnabledMock.mockReturnValue(true); + const buffer = new ArrayBuffer(4); + fetchMock.mockImplementation( + async ( + _url: string, + options?: { method?: string; headers?: { Range?: string } } + ) => { + if (options?.method === 'HEAD') { + return { ok: true, headers: { get: () => null } }; + } + if (options?.headers?.Range) { + return { ok: true, status: 200 }; // server ignored the Range request + } + return { ok: true, arrayBuffer: async () => buffer }; + } + ); + + await expect( + loadRemoteHttpSource('https://example.com/stream.mp3') + ).resolves.toBe(buffer); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + it('downloads when forceDownload is true even if byte ranges work', async () => { isFfmpegEnabledMock.mockReturnValue(true); const buffer = new ArrayBuffer(8);