"""The tests for hls streams.""" from datetime import datetime, timedelta from unittest.mock import patch from urllib.parse import urlparse import av import pytest from homeassistant.components.stream import create_stream from homeassistant.components.stream.const import ( HLS_PROVIDER, MAX_SEGMENTS, NUM_PLAYLIST_SEGMENTS, ) from homeassistant.components.stream.core import Part, Segment from homeassistant.const import HTTP_NOT_FOUND from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed from tests.components.stream.common import generate_h264_video STREAM_SOURCE = "some-stream-source" INIT_BYTES = b"init" FAKE_PAYLOAD = b"fake-payload" SEGMENT_DURATION = 10 TEST_TIMEOUT = 5.0 # Lower than 9s home assistant timeout MAX_ABORT_SEGMENTS = 20 # Abort test to avoid looping forever FAKE_TIME = datetime.utcnow() class HlsClient: """Test fixture for fetching the hls stream.""" def __init__(self, http_client, parsed_url): """Initialize HlsClient.""" self.http_client = http_client self.parsed_url = parsed_url async def get(self, path=None): """Fetch the hls stream for the specified path.""" url = self.parsed_url.path if path: # Strip off the master playlist suffix and replace with path url = "/".join(self.parsed_url.path.split("/")[:-1]) + path return await self.http_client.get(url) @pytest.fixture def hls_stream(hass, hass_client): """Create test fixture for creating an HLS client for a stream.""" async def create_client_for_stream(stream): http_client = await hass_client() parsed_url = urlparse(stream.endpoint_url(HLS_PROVIDER)) return HlsClient(http_client, parsed_url) return create_client_for_stream def make_segment(segment, discontinuity=False): """Create a playlist response for a segment.""" response = [] if discontinuity: response.extend( [ "#EXT-X-DISCONTINUITY", "#EXT-X-PROGRAM-DATE-TIME:" + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", ] ) response.extend([f"#EXTINF:{SEGMENT_DURATION:.3f},", f"./segment/{segment}.m4s"]) return "\n".join(response) def make_playlist(sequence, segments, discontinuity_sequence=0): """Create a an hls playlist response for tests to assert on.""" response = [ "#EXTM3U", "#EXT-X-VERSION:6", "#EXT-X-INDEPENDENT-SEGMENTS", '#EXT-X-MAP:URI="init.mp4"', "#EXT-X-TARGETDURATION:10", f"#EXT-X-MEDIA-SEQUENCE:{sequence}", f"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuity_sequence}", "#EXT-X-PROGRAM-DATE-TIME:" + FAKE_TIME.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", f"#EXT-X-START:TIME-OFFSET=-{1.5*SEGMENT_DURATION:.3f}", ] response.extend(segments) response.append("") return "\n".join(response) async def test_hls_stream(hass, hls_stream, stream_worker_sync): """ Test hls stream. Purposefully not mocking anything here to test full integration with the stream component. """ await async_setup_component(hass, "stream", {"stream": {}}) stream_worker_sync.pause() # Setup demo HLS track source = generate_h264_video() stream = create_stream(hass, source, {}) # Request stream stream.add_provider(HLS_PROVIDER) stream.start() hls_client = await hls_stream(stream) # Fetch playlist playlist_response = await hls_client.get() assert playlist_response.status == 200 # Fetch init playlist = await playlist_response.text() init_response = await hls_client.get("/init.mp4") assert init_response.status == 200 # Fetch segment playlist = await playlist_response.text() segment_url = "/" + playlist.splitlines()[-1] segment_response = await hls_client.get(segment_url) assert segment_response.status == 200 stream_worker_sync.resume() # Stop stream, if it hasn't quit already stream.stop() # Ensure playlist not accessible after stream ends fail_response = await hls_client.get() assert fail_response.status == HTTP_NOT_FOUND async def test_stream_timeout(hass, hass_client, stream_worker_sync): """Test hls stream timeout.""" await async_setup_component(hass, "stream", {"stream": {}}) stream_worker_sync.pause() # Setup demo HLS track source = generate_h264_video() stream = create_stream(hass, source, {}) # Request stream stream.add_provider(HLS_PROVIDER) stream.start() url = stream.endpoint_url(HLS_PROVIDER) http_client = await hass_client() # Fetch playlist parsed_url = urlparse(url) playlist_response = await http_client.get(parsed_url.path) assert playlist_response.status == 200 # Wait a minute future = dt_util.utcnow() + timedelta(minutes=1) async_fire_time_changed(hass, future) # Fetch again to reset timer playlist_response = await http_client.get(parsed_url.path) assert playlist_response.status == 200 stream_worker_sync.resume() # Wait 5 minutes future = dt_util.utcnow() + timedelta(minutes=5) async_fire_time_changed(hass, future) await hass.async_block_till_done() # Ensure playlist not accessible fail_response = await http_client.get(parsed_url.path) assert fail_response.status == HTTP_NOT_FOUND async def test_stream_timeout_after_stop(hass, hass_client, stream_worker_sync): """Test hls stream timeout after the stream has been stopped already.""" await async_setup_component(hass, "stream", {"stream": {}}) stream_worker_sync.pause() # Setup demo HLS track source = generate_h264_video() stream = create_stream(hass, source, {}) # Request stream stream.add_provider(HLS_PROVIDER) stream.start() stream_worker_sync.resume() stream.stop() # Wait 5 minutes and fire callback. Stream should already have been # stopped so this is a no-op. future = dt_util.utcnow() + timedelta(minutes=5) async_fire_time_changed(hass, future) await hass.async_block_till_done() async def test_stream_keepalive(hass): """Test hls stream retries the stream when keepalive=True.""" await async_setup_component(hass, "stream", {"stream": {}}) # Setup demo HLS track source = "test_stream_keepalive_source" stream = create_stream(hass, source, {}) track = stream.add_provider(HLS_PROVIDER) track.num_segments = 2 cur_time = 0 def time_side_effect(): nonlocal cur_time if cur_time >= 80: stream.keepalive = False # Thread should exit and be joinable. cur_time += 40 return cur_time with patch("av.open") as av_open, patch( "homeassistant.components.stream.time" ) as mock_time, patch( "homeassistant.components.stream.STREAM_RESTART_INCREMENT", 0 ): av_open.side_effect = av.error.InvalidDataError(-2, "error") mock_time.time.side_effect = time_side_effect # Request stream stream.keepalive = True stream.start() stream._thread.join() stream._thread = None assert av_open.call_count == 2 # Stop stream, if it hasn't quit already stream.stop() async def test_hls_playlist_view_no_output(hass, hass_client, hls_stream): """Test rendering the hls playlist with no output segments.""" await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, STREAM_SOURCE, {}) stream.add_provider(HLS_PROVIDER) hls_client = await hls_stream(stream) # Fetch playlist resp = await hls_client.get("/playlist.m3u8") assert resp.status == 404 async def test_hls_playlist_view(hass, hls_stream, stream_worker_sync): """Test rendering the hls playlist with 1 and 2 output segments.""" await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) for i in range(2): segment = Segment(sequence=i, duration=SEGMENT_DURATION, start_time=FAKE_TIME) hls.put(segment) await hass.async_block_till_done() hls_client = await hls_stream(stream) resp = await hls_client.get("/playlist.m3u8") assert resp.status == 200 assert await resp.text() == make_playlist( sequence=0, segments=[make_segment(0), make_segment(1)] ) segment = Segment(sequence=2, duration=SEGMENT_DURATION, start_time=FAKE_TIME) hls.put(segment) await hass.async_block_till_done() resp = await hls_client.get("/playlist.m3u8") assert resp.status == 200 assert await resp.text() == make_playlist( sequence=0, segments=[make_segment(0), make_segment(1), make_segment(2)] ) stream_worker_sync.resume() stream.stop() async def test_hls_max_segments(hass, hls_stream, stream_worker_sync): """Test rendering the hls playlist with more segments than the segment deque can hold.""" await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) hls_client = await hls_stream(stream) # Produce enough segments to overfill the output buffer by one for sequence in range(MAX_SEGMENTS + 1): segment = Segment( sequence=sequence, duration=SEGMENT_DURATION, start_time=FAKE_TIME ) hls.put(segment) await hass.async_block_till_done() resp = await hls_client.get("/playlist.m3u8") assert resp.status == 200 # Only NUM_PLAYLIST_SEGMENTS are returned in the playlist. start = MAX_SEGMENTS + 1 - NUM_PLAYLIST_SEGMENTS segments = [] for sequence in range(start, MAX_SEGMENTS + 1): segments.append(make_segment(sequence)) assert await resp.text() == make_playlist(sequence=start, segments=segments) # Fetch the actual segments with a fake byte payload for segment in hls.get_segments(): segment.init = INIT_BYTES segment.parts = [ Part( duration=SEGMENT_DURATION, has_keyframe=True, data=FAKE_PAYLOAD, ) ] # The segment that fell off the buffer is not accessible segment_response = await hls_client.get("/segment/0.m4s") assert segment_response.status == 404 # However all segments in the buffer are accessible, even those that were not in the playlist. for sequence in range(1, MAX_SEGMENTS + 1): segment_response = await hls_client.get(f"/segment/{sequence}.m4s") assert segment_response.status == 200 stream_worker_sync.resume() stream.stop() async def test_hls_playlist_view_discontinuity(hass, hls_stream, stream_worker_sync): """Test a discontinuity across segments in the stream with 3 segments.""" await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) segment = Segment( sequence=0, stream_id=0, duration=SEGMENT_DURATION, start_time=FAKE_TIME ) hls.put(segment) segment = Segment( sequence=1, stream_id=0, duration=SEGMENT_DURATION, start_time=FAKE_TIME ) hls.put(segment) segment = Segment( sequence=2, stream_id=1, duration=SEGMENT_DURATION, start_time=FAKE_TIME, ) hls.put(segment) await hass.async_block_till_done() hls_client = await hls_stream(stream) resp = await hls_client.get("/playlist.m3u8") assert resp.status == 200 assert await resp.text() == make_playlist( sequence=0, segments=[ make_segment(0), make_segment(1), make_segment(2, discontinuity=True), ], ) stream_worker_sync.resume() stream.stop() async def test_hls_max_segments_discontinuity(hass, hls_stream, stream_worker_sync): """Test a discontinuity with more segments than the segment deque can hold.""" await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) hls_client = await hls_stream(stream) segment = Segment( sequence=0, stream_id=0, duration=SEGMENT_DURATION, start_time=FAKE_TIME ) hls.put(segment) # Produce enough segments to overfill the output buffer by one for sequence in range(MAX_SEGMENTS + 1): segment = Segment( sequence=sequence, stream_id=1, duration=SEGMENT_DURATION, start_time=FAKE_TIME, ) hls.put(segment) await hass.async_block_till_done() resp = await hls_client.get("/playlist.m3u8") assert resp.status == 200 # Only NUM_PLAYLIST_SEGMENTS are returned in the playlist causing the # EXT-X-DISCONTINUITY tag to be omitted and EXT-X-DISCONTINUITY-SEQUENCE # returned instead. start = MAX_SEGMENTS + 1 - NUM_PLAYLIST_SEGMENTS segments = [] for sequence in range(start, MAX_SEGMENTS + 1): segments.append(make_segment(sequence)) assert await resp.text() == make_playlist( sequence=start, discontinuity_sequence=1, segments=segments, ) stream_worker_sync.resume() stream.stop()