music-assistant-server

8.2 KBPY
test_hls.py
8.2 KB254 lines • python
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