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);