From 11e9109fb2a158975df372bd55096cd897b4a182 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 15 Aug 2021 12:03:53 -0700 Subject: [PATCH] Add RESOLUTION attribute to EXT-X-STREAM-INF --- homeassistant/components/stream/core.py | 18 ++++++++++++++++ homeassistant/components/stream/hls.py | 5 ++++- homeassistant/components/stream/worker.py | 11 +++++++++- tests/components/stream/test_hls.py | 26 +++++++++++++++++++++++ tests/components/stream/test_worker.py | 6 +++++- 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index d840bfaf858191..2b442d51fbb919 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -55,6 +55,14 @@ def get_bytes_without_init(self) -> bytes: return b"".join([part.data for part in self.parts]) +@attr.s +class Resolution: + """Represents the resolution of the stream.""" + + width: int = attr.ib() + height: int = attr.ib() + + class IdleTimer: """Invoke a callback after an inactivity timeout. @@ -111,6 +119,7 @@ def __init__( self.idle_timer = idle_timer self._event = asyncio.Event() self._segments: deque[Segment] = deque(maxlen=deque_maxlen) + self._resolution: Resolution | None = None @property def name(self) -> str | None: @@ -148,6 +157,11 @@ def target_duration(self) -> float: return TARGET_SEGMENT_DURATION return max(durations) + @property + def resolution(self) -> Resolution | None: + """Return the resolution of the video stream.""" + return self._resolution + def get_segment(self, sequence: int) -> Segment | None: """Retrieve a specific segment.""" # Most hits will come in the most recent segments, so iterate reversed @@ -165,6 +179,10 @@ async def recv(self) -> bool: await self._event.wait() return self.last_segment is not None + def setup(self, resolution: Resolution | None) -> None: + """Initialize properties determined at stream start.""" + self._resolution = resolution + def put(self, segment: Segment) -> None: """Store output.""" self._hass.loop.call_soon_threadsafe(self._async_put, segment) diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 7f11bc09655d76..dd953fcaf80d2d 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -53,9 +53,12 @@ def render(track: StreamOutput) -> str: * 1.2 ) codecs = get_codec_string(segment.init) + attributes = f'BANDWIDTH={bandwidth},CODECS="{codecs}"' + if resolution := track.resolution: + attributes += f",RESOLUTION={resolution.width}x{resolution.height}" lines = [ "#EXTM3U", - f'#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},CODECS="{codecs}"', + f"#EXT-X-STREAM-INF:{attributes}", "playlist.m3u8", ] return "\n".join(lines) + "\n" diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 69def43b2a2834..cf849ccef8c048 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -21,7 +21,7 @@ SOURCE_TIMEOUT, TARGET_PART_DURATION, ) -from .core import Part, Segment, StreamOutput +from .core import Part, Resolution, Segment, StreamOutput _LOGGER = logging.getLogger(__name__) @@ -45,6 +45,7 @@ def __init__( self._av_output: av.container.OutputContainer = None self._input_video_stream: av.video.VideoStream = None self._input_audio_stream: av.audio.stream.AudioStream | None = None + self._resolution: Resolution | None = None self._output_video_stream: av.video.VideoStream = None self._output_audio_stream: av.audio.stream.AudioStream | None = None self._segment: Segment | None = None @@ -88,6 +89,10 @@ def set_streams( """Initialize output buffer with streams from container.""" self._input_video_stream = video_stream self._input_audio_stream = audio_stream + if video_stream.width and video_stream.height: + self._resolution = Resolution( + width=video_stream.width, height=video_stream.height + ) def reset(self, video_dts: int) -> None: """Initialize a new stream segment.""" @@ -111,6 +116,10 @@ def reset(self, video_dts: int) -> None: self._output_audio_stream = self._av_output.add_stream( template=self._input_audio_stream ) + # StreamOutput setup only needs to happen once, however the worker does + # not currently track StreamOutput lifecycles so invoke every reset. + for stream_output in self._outputs_callback().values(): + stream_output.setup(self._resolution) def mux_packet(self, packet: av.Packet) -> None: """Mux a packet to the appropriate output stream.""" diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 919f71c8509a34..9743c268f78da1 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -140,6 +140,32 @@ async def test_hls_stream(hass, hls_stream, stream_worker_sync): assert fail_response.status == HTTP_NOT_FOUND +async def test_hls_stream_master_playlist(hass, hls_stream, stream_worker_sync): + """Exercise the master playlist details.""" + 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 + assert await playlist_response.text() == ( + "#EXTM3U\n" + '#EXT-X-STREAM-INF:BANDWIDTH=77844,CODECS="avc1.640015",RESOLUTION=480x320\n' + "playlist.m3u8\n" + ) + + async def test_stream_timeout(hass, hass_client, stream_worker_sync): """Test hls stream timeout.""" await async_setup_component(hass, "stream", {"stream": {}}) diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index ffbeb44d79e4e8..e09fd49911bab7 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -55,11 +55,15 @@ class FakePyAvStream: """A fake pyav Stream.""" - def __init__(self, name, rate): + def __init__( + self, name: str, rate: fractions.Fraction, width: int = None, height: int = None + ): """Initialize the stream.""" self.name = name self.time_base = fractions.Fraction(1, rate) self.profile = "ignored-profile" + self.width = width + self.height = height class FakeCodec: name = "aac"