mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 20:27:08 +00:00
Add discontinuity support to HLS streams and fix nest expiring stream urls (#46683)
* Support HLS stream discontinuity. * Clarify discontinuity comments * Signal a stream discontinuity on restart due to stream error * Apply suggestions from code review Co-authored-by: uvjustin <46082645+uvjustin@users.noreply.github.com> * Simplify stream discontinuity logic
This commit is contained in:
parent
62cfe24ed4
commit
88d143a644
@ -170,7 +170,7 @@ class Stream:
|
|||||||
|
|
||||||
def update_source(self, new_source):
|
def update_source(self, new_source):
|
||||||
"""Restart the stream with a new stream source."""
|
"""Restart the stream with a new stream source."""
|
||||||
_LOGGER.debug("Updating stream source %s", self.source)
|
_LOGGER.debug("Updating stream source %s", new_source)
|
||||||
self.source = new_source
|
self.source = new_source
|
||||||
self._fast_restart_once = True
|
self._fast_restart_once = True
|
||||||
self._thread_quit.set()
|
self._thread_quit.set()
|
||||||
@ -179,12 +179,14 @@ class Stream:
|
|||||||
"""Handle consuming streams and restart keepalive streams."""
|
"""Handle consuming streams and restart keepalive streams."""
|
||||||
# Keep import here so that we can import stream integration without installing reqs
|
# Keep import here so that we can import stream integration without installing reqs
|
||||||
# pylint: disable=import-outside-toplevel
|
# pylint: disable=import-outside-toplevel
|
||||||
from .worker import stream_worker
|
from .worker import SegmentBuffer, stream_worker
|
||||||
|
|
||||||
|
segment_buffer = SegmentBuffer(self.outputs)
|
||||||
wait_timeout = 0
|
wait_timeout = 0
|
||||||
while not self._thread_quit.wait(timeout=wait_timeout):
|
while not self._thread_quit.wait(timeout=wait_timeout):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
stream_worker(self.source, self.options, self.outputs, self._thread_quit)
|
stream_worker(self.source, self.options, segment_buffer, self._thread_quit)
|
||||||
|
segment_buffer.discontinuity()
|
||||||
if not self.keepalive or self._thread_quit.is_set():
|
if not self.keepalive or self._thread_quit.is_set():
|
||||||
if self._fast_restart_once:
|
if self._fast_restart_once:
|
||||||
# The stream source is updated, restart without any delay.
|
# The stream source is updated, restart without any delay.
|
||||||
|
@ -30,6 +30,8 @@ class Segment:
|
|||||||
sequence: int = attr.ib()
|
sequence: int = attr.ib()
|
||||||
segment: io.BytesIO = attr.ib()
|
segment: io.BytesIO = attr.ib()
|
||||||
duration: float = attr.ib()
|
duration: float = attr.ib()
|
||||||
|
# For detecting discontinuities across stream restarts
|
||||||
|
stream_id: int = attr.ib(default=0)
|
||||||
|
|
||||||
|
|
||||||
class IdleTimer:
|
class IdleTimer:
|
||||||
|
@ -78,21 +78,27 @@ class HlsPlaylistView(StreamView):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def render_playlist(track):
|
def render_playlist(track):
|
||||||
"""Render playlist."""
|
"""Render playlist."""
|
||||||
segments = track.segments[-NUM_PLAYLIST_SEGMENTS:]
|
segments = list(track.get_segment())[-NUM_PLAYLIST_SEGMENTS:]
|
||||||
|
|
||||||
if not segments:
|
if not segments:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
playlist = ["#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0])]
|
playlist = [
|
||||||
|
"#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0].sequence),
|
||||||
|
"#EXT-X-DISCONTINUITY-SEQUENCE:{}".format(segments[0].stream_id),
|
||||||
|
]
|
||||||
|
|
||||||
for sequence in segments:
|
last_stream_id = segments[0].stream_id
|
||||||
segment = track.get_segment(sequence)
|
for segment in segments:
|
||||||
|
if last_stream_id != segment.stream_id:
|
||||||
|
playlist.append("#EXT-X-DISCONTINUITY")
|
||||||
playlist.extend(
|
playlist.extend(
|
||||||
[
|
[
|
||||||
"#EXTINF:{:.04f},".format(float(segment.duration)),
|
"#EXTINF:{:.04f},".format(float(segment.duration)),
|
||||||
f"./segment/{segment.sequence}.m4s",
|
f"./segment/{segment.sequence}.m4s",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
last_stream_id = segment.stream_id
|
||||||
|
|
||||||
return playlist
|
return playlist
|
||||||
|
|
||||||
|
@ -49,16 +49,22 @@ def create_stream_buffer(stream_output, video_stream, audio_stream, sequence):
|
|||||||
class SegmentBuffer:
|
class SegmentBuffer:
|
||||||
"""Buffer for writing a sequence of packets to the output as a segment."""
|
"""Buffer for writing a sequence of packets to the output as a segment."""
|
||||||
|
|
||||||
def __init__(self, video_stream, audio_stream, outputs_callback) -> None:
|
def __init__(self, outputs_callback) -> None:
|
||||||
"""Initialize SegmentBuffer."""
|
"""Initialize SegmentBuffer."""
|
||||||
self._video_stream = video_stream
|
self._stream_id = 0
|
||||||
self._audio_stream = audio_stream
|
self._video_stream = None
|
||||||
|
self._audio_stream = None
|
||||||
self._outputs_callback = outputs_callback
|
self._outputs_callback = outputs_callback
|
||||||
# tuple of StreamOutput, StreamBuffer
|
# tuple of StreamOutput, StreamBuffer
|
||||||
self._outputs = []
|
self._outputs = []
|
||||||
self._sequence = 0
|
self._sequence = 0
|
||||||
self._segment_start_pts = None
|
self._segment_start_pts = None
|
||||||
|
|
||||||
|
def set_streams(self, video_stream, audio_stream):
|
||||||
|
"""Initialize output buffer with streams from container."""
|
||||||
|
self._video_stream = video_stream
|
||||||
|
self._audio_stream = audio_stream
|
||||||
|
|
||||||
def reset(self, video_pts):
|
def reset(self, video_pts):
|
||||||
"""Initialize a new stream segment."""
|
"""Initialize a new stream segment."""
|
||||||
# Keep track of the number of segments we've processed
|
# Keep track of the number of segments we've processed
|
||||||
@ -103,7 +109,16 @@ class SegmentBuffer:
|
|||||||
"""Create a segment from the buffered packets and write to output."""
|
"""Create a segment from the buffered packets and write to output."""
|
||||||
for (buffer, stream_output) in self._outputs:
|
for (buffer, stream_output) in self._outputs:
|
||||||
buffer.output.close()
|
buffer.output.close()
|
||||||
stream_output.put(Segment(self._sequence, buffer.segment, duration))
|
stream_output.put(
|
||||||
|
Segment(self._sequence, buffer.segment, duration, self._stream_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def discontinuity(self):
|
||||||
|
"""Mark the stream as having been restarted."""
|
||||||
|
# Preserving sequence and stream_id here keep the HLS playlist logic
|
||||||
|
# simple to check for discontinuity at output time, and to determine
|
||||||
|
# the discontinuity sequence number.
|
||||||
|
self._stream_id += 1
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close all StreamBuffers."""
|
"""Close all StreamBuffers."""
|
||||||
@ -111,7 +126,7 @@ class SegmentBuffer:
|
|||||||
buffer.output.close()
|
buffer.output.close()
|
||||||
|
|
||||||
|
|
||||||
def stream_worker(source, options, outputs_callback, quit_event):
|
def stream_worker(source, options, segment_buffer, quit_event):
|
||||||
"""Handle consuming streams."""
|
"""Handle consuming streams."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -143,8 +158,6 @@ def stream_worker(source, options, outputs_callback, quit_event):
|
|||||||
last_dts = {video_stream: float("-inf"), audio_stream: float("-inf")}
|
last_dts = {video_stream: float("-inf"), audio_stream: float("-inf")}
|
||||||
# Keep track of consecutive packets without a dts to detect end of stream.
|
# Keep track of consecutive packets without a dts to detect end of stream.
|
||||||
missing_dts = 0
|
missing_dts = 0
|
||||||
# Holds the buffers for each stream provider
|
|
||||||
segment_buffer = SegmentBuffer(video_stream, audio_stream, outputs_callback)
|
|
||||||
# The video pts at the beginning of the segment
|
# The video pts at the beginning of the segment
|
||||||
segment_start_pts = None
|
segment_start_pts = None
|
||||||
# Because of problems 1 and 2 below, we need to store the first few packets and replay them
|
# Because of problems 1 and 2 below, we need to store the first few packets and replay them
|
||||||
@ -225,6 +238,7 @@ def stream_worker(source, options, outputs_callback, quit_event):
|
|||||||
container.close()
|
container.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
segment_buffer.set_streams(video_stream, audio_stream)
|
||||||
segment_buffer.reset(segment_start_pts)
|
segment_buffer.reset(segment_start_pts)
|
||||||
|
|
||||||
while not quit_event.is_set():
|
while not quit_event.is_set():
|
||||||
|
@ -51,7 +51,16 @@ def hls_stream(hass, hass_client):
|
|||||||
return create_client_for_stream
|
return create_client_for_stream
|
||||||
|
|
||||||
|
|
||||||
def playlist_response(sequence, segments):
|
def make_segment(segment, discontinuity=False):
|
||||||
|
"""Create a playlist response for a segment."""
|
||||||
|
response = []
|
||||||
|
if discontinuity:
|
||||||
|
response.append("#EXT-X-DISCONTINUITY")
|
||||||
|
response.extend(["#EXTINF:10.0000,", f"./segment/{segment}.m4s"]),
|
||||||
|
return "\n".join(response)
|
||||||
|
|
||||||
|
|
||||||
|
def make_playlist(sequence, discontinuity_sequence=0, segments=[]):
|
||||||
"""Create a an hls playlist response for tests to assert on."""
|
"""Create a an hls playlist response for tests to assert on."""
|
||||||
response = [
|
response = [
|
||||||
"#EXTM3U",
|
"#EXTM3U",
|
||||||
@ -59,14 +68,9 @@ def playlist_response(sequence, segments):
|
|||||||
"#EXT-X-TARGETDURATION:10",
|
"#EXT-X-TARGETDURATION:10",
|
||||||
'#EXT-X-MAP:URI="init.mp4"',
|
'#EXT-X-MAP:URI="init.mp4"',
|
||||||
f"#EXT-X-MEDIA-SEQUENCE:{sequence}",
|
f"#EXT-X-MEDIA-SEQUENCE:{sequence}",
|
||||||
|
f"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuity_sequence}",
|
||||||
]
|
]
|
||||||
for segment in segments:
|
response.extend(segments)
|
||||||
response.extend(
|
|
||||||
[
|
|
||||||
"#EXTINF:10.0000,",
|
|
||||||
f"./segment/{segment}.m4s",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
response.append("")
|
response.append("")
|
||||||
return "\n".join(response)
|
return "\n".join(response)
|
||||||
|
|
||||||
@ -289,13 +293,15 @@ async def test_hls_playlist_view(hass, hls_stream, stream_worker_sync):
|
|||||||
|
|
||||||
resp = await hls_client.get("/playlist.m3u8")
|
resp = await hls_client.get("/playlist.m3u8")
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
assert await resp.text() == playlist_response(sequence=1, segments=[1])
|
assert await resp.text() == make_playlist(sequence=1, segments=[make_segment(1)])
|
||||||
|
|
||||||
hls.put(Segment(2, SEQUENCE_BYTES, DURATION))
|
hls.put(Segment(2, SEQUENCE_BYTES, DURATION))
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
resp = await hls_client.get("/playlist.m3u8")
|
resp = await hls_client.get("/playlist.m3u8")
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
assert await resp.text() == playlist_response(sequence=1, segments=[1, 2])
|
assert await resp.text() == make_playlist(
|
||||||
|
sequence=1, segments=[make_segment(1), make_segment(2)]
|
||||||
|
)
|
||||||
|
|
||||||
stream_worker_sync.resume()
|
stream_worker_sync.resume()
|
||||||
stream.stop()
|
stream.stop()
|
||||||
@ -321,8 +327,12 @@ async def test_hls_max_segments(hass, hls_stream, stream_worker_sync):
|
|||||||
|
|
||||||
# Only NUM_PLAYLIST_SEGMENTS are returned in the playlist.
|
# Only NUM_PLAYLIST_SEGMENTS are returned in the playlist.
|
||||||
start = MAX_SEGMENTS + 2 - NUM_PLAYLIST_SEGMENTS
|
start = MAX_SEGMENTS + 2 - NUM_PLAYLIST_SEGMENTS
|
||||||
assert await resp.text() == playlist_response(
|
segments = []
|
||||||
sequence=start, segments=range(start, MAX_SEGMENTS + 2)
|
for sequence in range(start, MAX_SEGMENTS + 2):
|
||||||
|
segments.append(make_segment(sequence))
|
||||||
|
assert await resp.text() == make_playlist(
|
||||||
|
sequence=start,
|
||||||
|
segments=segments,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch the actual segments with a fake byte payload
|
# Fetch the actual segments with a fake byte payload
|
||||||
@ -340,3 +350,70 @@ async def test_hls_max_segments(hass, hls_stream, stream_worker_sync):
|
|||||||
|
|
||||||
stream_worker_sync.resume()
|
stream_worker_sync.resume()
|
||||||
stream.stop()
|
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.hls_output()
|
||||||
|
|
||||||
|
hls.put(Segment(1, SEQUENCE_BYTES, DURATION, stream_id=0))
|
||||||
|
hls.put(Segment(2, SEQUENCE_BYTES, DURATION, stream_id=0))
|
||||||
|
hls.put(Segment(3, SEQUENCE_BYTES, DURATION, stream_id=1))
|
||||||
|
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=1,
|
||||||
|
segments=[
|
||||||
|
make_segment(1),
|
||||||
|
make_segment(2),
|
||||||
|
make_segment(3, 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.hls_output()
|
||||||
|
|
||||||
|
hls_client = await hls_stream(stream)
|
||||||
|
|
||||||
|
hls.put(Segment(1, SEQUENCE_BYTES, DURATION, stream_id=0))
|
||||||
|
|
||||||
|
# Produce enough segments to overfill the output buffer by one
|
||||||
|
for sequence in range(1, MAX_SEGMENTS + 2):
|
||||||
|
hls.put(Segment(sequence, SEQUENCE_BYTES, DURATION, stream_id=1))
|
||||||
|
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 + 2 - NUM_PLAYLIST_SEGMENTS
|
||||||
|
segments = []
|
||||||
|
for sequence in range(start, MAX_SEGMENTS + 2):
|
||||||
|
segments.append(make_segment(sequence))
|
||||||
|
assert await resp.text() == make_playlist(
|
||||||
|
sequence=start,
|
||||||
|
discontinuity_sequence=1,
|
||||||
|
segments=segments,
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_worker_sync.resume()
|
||||||
|
stream.stop()
|
||||||
|
@ -27,7 +27,7 @@ from homeassistant.components.stream.const import (
|
|||||||
MIN_SEGMENT_DURATION,
|
MIN_SEGMENT_DURATION,
|
||||||
PACKETS_TO_WAIT_FOR_AUDIO,
|
PACKETS_TO_WAIT_FOR_AUDIO,
|
||||||
)
|
)
|
||||||
from homeassistant.components.stream.worker import stream_worker
|
from homeassistant.components.stream.worker import SegmentBuffer, stream_worker
|
||||||
|
|
||||||
STREAM_SOURCE = "some-stream-source"
|
STREAM_SOURCE = "some-stream-source"
|
||||||
# Formats here are arbitrary, not exercised by tests
|
# Formats here are arbitrary, not exercised by tests
|
||||||
@ -197,7 +197,8 @@ async def async_decode_stream(hass, packets, py_av=None):
|
|||||||
"homeassistant.components.stream.core.StreamOutput.put",
|
"homeassistant.components.stream.core.StreamOutput.put",
|
||||||
side_effect=py_av.capture_buffer.capture_output_segment,
|
side_effect=py_av.capture_buffer.capture_output_segment,
|
||||||
):
|
):
|
||||||
stream_worker(STREAM_SOURCE, {}, stream.outputs, threading.Event())
|
segment_buffer = SegmentBuffer(stream.outputs)
|
||||||
|
stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event())
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
return py_av.capture_buffer
|
return py_av.capture_buffer
|
||||||
@ -209,7 +210,8 @@ async def test_stream_open_fails(hass):
|
|||||||
stream.hls_output()
|
stream.hls_output()
|
||||||
with patch("av.open") as av_open:
|
with patch("av.open") as av_open:
|
||||||
av_open.side_effect = av.error.InvalidDataError(-2, "error")
|
av_open.side_effect = av.error.InvalidDataError(-2, "error")
|
||||||
stream_worker(STREAM_SOURCE, {}, stream.outputs, threading.Event())
|
segment_buffer = SegmentBuffer(stream.outputs)
|
||||||
|
stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event())
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
av_open.assert_called_once()
|
av_open.assert_called_once()
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user