Skip to content

Commit 645fb37

Browse files
authored
[Music] Parse CRLF correctly for music detailed endpoint (#741)
* Fix finding body start * Add tests
1 parent 78ed67e commit 645fb37

2 files changed

Lines changed: 96 additions & 20 deletions

File tree

src/elevenlabs/music_custom.py

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@
1616
logger = logging.getLogger(__name__)
1717

1818

19+
def _find_audio_start(data: bytes, start: int) -> int:
20+
"""Find the blank line separating headers from body, returning the index of the body start."""
21+
crlf_pos = data.find(b"\r\n\r\n", start)
22+
if crlf_pos != -1:
23+
return crlf_pos + 4
24+
25+
lf_pos = data.find(b"\n\n", start)
26+
if lf_pos != -1:
27+
return lf_pos + 2
28+
29+
raise ValueError("Could not find header/body separator in audio part")
30+
31+
1932
@dataclass
2033
class SongMetadata:
2134
title: str
@@ -145,16 +158,7 @@ def _parse_multipart(self, stream: typing.Iterator[bytes]) -> MultipartResponse:
145158
raise ValueError('Could not find audio part boundary')
146159

147160
# Find the start of audio data (after headers and empty line)
148-
audio_start = second_boundary + len(boundary_bytes)
149-
150-
# Skip past the headers to find the empty line (\n\n)
151-
while audio_start < len(response_bytes) - 1:
152-
if (response_bytes[audio_start] == 0x0A and
153-
response_bytes[audio_start + 1] == 0x0A):
154-
# Found \n\n - audio starts after this
155-
audio_start += 2
156-
break
157-
audio_start += 1
161+
audio_start = _find_audio_start(response_bytes, second_boundary + len(boundary_bytes))
158162

159163
# Audio goes until the end (or until we find another boundary)
160164
audio_buffer = response_bytes[audio_start:]
@@ -281,16 +285,7 @@ async def _parse_multipart_async(self, stream: typing.AsyncIterator[bytes]) -> M
281285
raise ValueError('Could not find audio part boundary')
282286

283287
# Find the start of audio data (after headers and empty line)
284-
audio_start = second_boundary + len(boundary_bytes)
285-
286-
# Skip past the headers to find the empty line (\n\n)
287-
while audio_start < len(response_bytes) - 1:
288-
if (response_bytes[audio_start] == 0x0A and
289-
response_bytes[audio_start + 1] == 0x0A):
290-
# Found \n\n - audio starts after this
291-
audio_start += 2
292-
break
293-
audio_start += 1
288+
audio_start = _find_audio_start(response_bytes, second_boundary + len(boundary_bytes))
294289

295290
# Audio goes until the end (or until we find another boundary)
296291
audio_buffer = response_bytes[audio_start:]

tests/test_music_multipart.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import json
2+
3+
import pytest
4+
5+
from elevenlabs.music_custom import MusicClient, _find_audio_start
6+
7+
SAMPLE_JSON = {"compositionPlan": {"sections": []}, "songMetadata": {"title": "Test"}}
8+
SAMPLE_AUDIO = bytes(range(256)) * 4
9+
10+
11+
def _build_multipart(line_ending: bytes) -> bytes:
12+
"""Build a realistic multipart response with the given line ending."""
13+
boundary = b"--boundary123"
14+
nl = line_ending
15+
16+
parts = []
17+
parts.append(boundary + nl)
18+
parts.append(b"Content-Type: application/json" + nl)
19+
parts.append(nl)
20+
parts.append(json.dumps(SAMPLE_JSON).encode("utf-8") + nl)
21+
parts.append(boundary + nl)
22+
parts.append(b"Content-Type: audio/mpeg" + nl)
23+
parts.append(b'Content-Disposition: attachment; filename="test_song.mp3"' + nl)
24+
parts.append(nl)
25+
parts.append(SAMPLE_AUDIO)
26+
parts.append(nl + boundary + b"--" + nl)
27+
28+
return b"".join(parts)
29+
30+
31+
class TestFindAudioStart:
32+
def test_crlf(self):
33+
data = b"Header: value\r\n\r\naudio data here"
34+
assert _find_audio_start(data, 0) == data.index(b"audio")
35+
36+
def test_lf(self):
37+
data = b"Header: value\n\naudio data here"
38+
assert _find_audio_start(data, 0) == data.index(b"audio")
39+
40+
def test_crlf_preferred_over_lf(self):
41+
data = b"Header: value\r\n\r\naudio data here"
42+
pos = _find_audio_start(data, 0)
43+
assert data[pos : pos + 5] == b"audio"
44+
45+
def test_raises_when_no_separator(self):
46+
with pytest.raises(ValueError, match="header/body separator"):
47+
_find_audio_start(b"no blank line here", 0)
48+
49+
def test_start_offset_respected(self):
50+
data = b"skip\n\nHeader: value\r\n\r\naudio"
51+
pos = _find_audio_start(data, 6)
52+
assert data[pos : pos + 5] == b"audio"
53+
54+
55+
class TestParseMultipart:
56+
@staticmethod
57+
def _parse(data: bytes):
58+
return MusicClient._parse_multipart(None, iter([data])) # type: ignore[arg-type]
59+
60+
def test_crlf_line_endings(self):
61+
data = _build_multipart(b"\r\n")
62+
result = self._parse(data)
63+
assert result.audio.startswith(SAMPLE_AUDIO)
64+
assert result.json == SAMPLE_JSON
65+
assert result.filename == "test_song.mp3"
66+
67+
def test_lf_line_endings(self):
68+
data = _build_multipart(b"\n")
69+
result = self._parse(data)
70+
assert result.audio.startswith(SAMPLE_AUDIO)
71+
assert result.json == SAMPLE_JSON
72+
assert result.filename == "test_song.mp3"
73+
74+
def test_audio_bytes_not_corrupted(self):
75+
for nl in [b"\r\n", b"\n"]:
76+
data = _build_multipart(nl)
77+
result = self._parse(data)
78+
assert result.audio[:4] == SAMPLE_AUDIO[:4], (
79+
f"First 4 bytes mismatch with {nl!r} line endings — possible byte offset error"
80+
)
81+
assert result.audio[: len(SAMPLE_AUDIO)] == SAMPLE_AUDIO

0 commit comments

Comments
 (0)