Skip to content
Open
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
18 changes: 9 additions & 9 deletions packages/react-native-audio-api/src/utils/remoteHttpSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@ async function detectHttpByteRanges(
): Promise<boolean> {
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.
}
Expand Down
56 changes: 56 additions & 0 deletions packages/react-native-audio-api/tests/remote-http-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading