/
/
/
1"""Tests for HLS playlist parsing utilities."""
2
3from __future__ import annotations
4
5import pytest
6from music_assistant_models.errors import InvalidDataError
7
8from music_assistant.helpers.hls import HLSMediaPlaylistParser, HLSMediaSegment
9
10
11def test_basic_vod_playlist() -> None:
12 """Test parsing basic VOD playlist with encryption (standard fMP4 format)."""
13 playlist_text = """#EXTM3U
14#EXT-X-VERSION:3
15#EXT-X-TARGETDURATION:6
16#EXT-X-MEDIA-SEQUENCE:0
17#EXT-X-PLAYLIST-TYPE:VOD
18#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key.bin"
19#EXT-X-MAP:URI="init.mp4"
20#EXTINF:5.967528,
21segment0.m4s
22#EXTINF:5.967528,
23segment1.m4s
24#EXTINF:5.967528,
25segment2.m4s
26#EXTINF:3.123456,
27segment3.m4s
28#EXT-X-ENDLIST
29"""
30 result = HLSMediaPlaylistParser(playlist_text).parse()
31
32 assert len(result.header_lines) == 5
33 assert len(result.segments) == 4
34 assert len(result.footer_lines) == 1
35
36 # Check total duration
37 total_duration = sum(segment.duration for segment in result.segments)
38 assert total_duration == pytest.approx(21.02604, rel=1e-6)
39
40 # Check first segment inherits MAP from header
41 assert result.segments[0].segment_url == "segment0.m4s"
42 assert result.segments[0].duration == pytest.approx(5.967528, rel=1e-6)
43 assert (
44 result.segments[0].key_line == '#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key.bin"'
45 )
46 assert result.segments[0].map_line == '#EXT-X-MAP:URI="init.mp4"'
47 assert result.segments[0].byterange_line is None
48 assert result.segments[0].discontinuity is False
49 assert result.segments[0].program_date_time is None
50
51 # Check last segment - also has MAP
52 assert result.segments[3].segment_url == "segment3.m4s"
53 assert result.segments[3].duration == pytest.approx(3.123456, rel=1e-6)
54 assert result.segments[3].map_line == '#EXT-X-MAP:URI="init.mp4"'
55
56
57def test_live_stream_with_program_date_time() -> None:
58 """Test parsing live stream with PROGRAM-DATE-TIME tags."""
59 playlist_text = """#EXTM3U
60#EXT-X-VERSION:3
61#EXT-X-TARGETDURATION:10
62#EXT-X-MEDIA-SEQUENCE:2680
63#EXT-X-PROGRAM-DATE-TIME:2025-11-27T10:15:00.000Z
64#EXTINF:9.009,
65https://example.com/segment2680.ts
66#EXTINF:9.009,
67https://example.com/segment2681.ts
68#EXT-X-DISCONTINUITY
69#EXT-X-PROGRAM-DATE-TIME:2025-11-27T10:15:20.000Z
70#EXTINF:9.009,
71https://example.com/segment2682.ts
72"""
73 result = HLSMediaPlaylistParser(playlist_text).parse()
74
75 assert len(result.segments) == 3
76
77 # First segment has PROGRAM-DATE-TIME
78 assert (
79 result.segments[0].program_date_time == "#EXT-X-PROGRAM-DATE-TIME:2025-11-27T10:15:00.000Z"
80 )
81 assert result.segments[0].discontinuity is False
82
83 # Second segment does not have PROGRAM-DATE-TIME (single-use tag)
84 assert result.segments[1].program_date_time is None
85 assert result.segments[1].discontinuity is False
86
87 # Third segment has both discontinuity and new PROGRAM-DATE-TIME
88 assert result.segments[2].discontinuity is True
89 assert (
90 result.segments[2].program_date_time == "#EXT-X-PROGRAM-DATE-TIME:2025-11-27T10:15:20.000Z"
91 )
92
93
94def test_byterange_segments() -> None:
95 """Test parsing playlist with byte-range segments."""
96 playlist_text = """#EXTM3U
97#EXT-X-VERSION:4
98#EXT-X-TARGETDURATION:10
99#EXT-X-MEDIA-SEQUENCE:0
100#EXT-X-MAP:URI="init.mp4"
101#EXTINF:10.0,
102#EXT-X-BYTERANGE:1000@0
103video.mp4
104#EXTINF:10.0,
105#EXT-X-BYTERANGE:1500
106video.mp4
107#EXTINF:10.0,
108#EXT-X-BYTERANGE:1200
109video.mp4
110#EXT-X-ENDLIST
111"""
112 result = HLSMediaPlaylistParser(playlist_text).parse()
113
114 assert len(result.segments) == 3
115 assert result.segments[0].byterange_line == "#EXT-X-BYTERANGE:1000@0"
116 assert result.segments[1].byterange_line == "#EXT-X-BYTERANGE:1500"
117 assert result.segments[2].byterange_line == "#EXT-X-BYTERANGE:1200"
118
119 # All segments use same file
120 assert result.segments[0].segment_url == "video.mp4"
121 assert result.segments[1].segment_url == "video.mp4"
122 assert result.segments[2].segment_url == "video.mp4"
123
124
125def test_multiple_encryption_keys() -> None:
126 """Test parsing playlist with multiple encryption keys (ad insertion scenario)."""
127 playlist_text = """#EXTM3U
128#EXT-X-VERSION:3
129#EXT-X-TARGETDURATION:15
130#EXT-X-MEDIA-SEQUENCE:0
131#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key1.bin"
132#EXTINF:10.0,
133segment0.ts
134#EXTINF:10.0,
135segment1.ts
136#EXT-X-DISCONTINUITY
137#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key2.bin"
138#EXTINF:15.0,
139ad_segment.ts
140#EXT-X-DISCONTINUITY
141#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key1.bin"
142#EXTINF:10.0,
143segment2.ts
144#EXT-X-ENDLIST
145"""
146 result = HLSMediaPlaylistParser(playlist_text).parse()
147
148 assert len(result.segments) == 4
149
150 # First two segments use key1
151 assert (
152 result.segments[0].key_line
153 == '#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key1.bin"'
154 )
155 assert (
156 result.segments[1].key_line
157 == '#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key1.bin"'
158 )
159 assert result.segments[1].discontinuity is False
160
161 # Ad segment has discontinuity and key2
162 assert result.segments[2].discontinuity is True
163 assert (
164 result.segments[2].key_line
165 == '#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key2.bin"'
166 )
167
168 # Back to key1 with discontinuity
169 assert result.segments[3].discontinuity is True
170 assert (
171 result.segments[3].key_line
172 == '#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key1.bin"'
173 )
174
175
176def test_segment_properties() -> None:
177 """Test HLSMediaSegment properties (duration, title) and comment handling."""
178 # Test duration extraction
179 segment = HLSMediaSegment(extinf_line="#EXTINF:5.967528,", segment_url="test.m4s")
180 assert segment.duration == pytest.approx(5.967528, rel=1e-6)
181
182 # Test duration with title
183 segment = HLSMediaSegment(extinf_line="#EXTINF:10.5,Track Title", segment_url="test.m4s")
184 assert segment.duration == pytest.approx(10.5, rel=1e-6)
185 assert segment.title == "Track Title"
186
187 # Test malformed EXTINF
188 segment = HLSMediaSegment(extinf_line="malformed", segment_url="test.m4s")
189 assert segment.duration == 0.0
190
191 # Test comment lines and title extraction in playlist
192 playlist_text = """#EXTM3U
193#EXT-X-VERSION:3
194# This is a comment
195#EXTINF:10.0,Test Title
196segment1.ts
197#EXTINF:10.0,
198segment2.ts
199#EXT-X-ENDLIST
200"""
201 result = HLSMediaPlaylistParser(playlist_text).parse()
202 assert len(result.segments) == 2
203 assert result.segments[0].title == "Test Title"
204 assert result.segments[1].title is None
205 assert all("# This" not in line for line in result.header_lines)
206
207
208def test_tag_inheritance() -> None:
209 """Test that EXT-X-KEY and EXT-X-MAP persist across segments until changed."""
210 playlist_text = """#EXTM3U
211#EXT-X-VERSION:3
212#EXT-X-KEY:METHOD=AES-128,URI="key1.bin"
213#EXT-X-MAP:URI="init.mp4"
214#EXTINF:10.0,
215segment1.ts
216#EXTINF:10.0,
217segment2.ts
218#EXT-X-KEY:METHOD=AES-128,URI="key2.bin"
219#EXT-X-MAP:URI="init2.mp4"
220#EXTINF:10.0,
221segment3.ts
222#EXT-X-ENDLIST
223"""
224 result = HLSMediaPlaylistParser(playlist_text).parse()
225
226 # First two segments inherit key1 and init.mp4
227 assert result.segments[0].key_line == '#EXT-X-KEY:METHOD=AES-128,URI="key1.bin"'
228 assert result.segments[0].map_line == '#EXT-X-MAP:URI="init.mp4"'
229 assert result.segments[1].key_line == '#EXT-X-KEY:METHOD=AES-128,URI="key1.bin"'
230 assert result.segments[1].map_line == '#EXT-X-MAP:URI="init.mp4"'
231
232 # Third segment uses key2 and init2.mp4
233 assert result.segments[2].key_line == '#EXT-X-KEY:METHOD=AES-128,URI="key2.bin"'
234 assert result.segments[2].map_line == '#EXT-X-MAP:URI="init2.mp4"'
235
236
237def test_invalid_playlists() -> None:
238 """Test error handling for invalid playlist formats."""
239 # No #EXTM3U header
240 with pytest.raises(InvalidDataError, match="must start with #EXTM3U"):
241 HLSMediaPlaylistParser("#EXTINF:10.0\nsegment.ts").parse()
242
243 # Empty playlist
244 with pytest.raises(InvalidDataError, match="must start with #EXTM3U"):
245 HLSMediaPlaylistParser("").parse()
246
247 # No segments
248 with pytest.raises(InvalidDataError, match="no segments found"):
249 HLSMediaPlaylistParser("#EXTM3U\n#EXT-X-VERSION:3").parse()
250
251 # EXTINF without segment URL
252 with pytest.raises(InvalidDataError, match="without preceding segment URL"):
253 HLSMediaPlaylistParser("#EXTM3U\n#EXTINF:10.0,\n#EXTINF:10.0,").parse()
254