Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions homeassistant/components/stream/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion homeassistant/components/stream/hls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion homeassistant/components/stream/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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."""
Expand Down
26 changes: 26 additions & 0 deletions tests/components/stream/test_hls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}})
Expand Down
6 changes: 5 additions & 1 deletion tests/components/stream/test_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down