core/homeassistant/components/stream/hls.py

437 lines
18 KiB
Python
Raw Normal View History

"""Provide functionality to stream HLS."""
from __future__ import annotations
2021-08-29 01:53:41 +00:00
import logging
from typing import TYPE_CHECKING, cast
from aiohttp import web
from homeassistant.core import HomeAssistant, callback
from .const import (
2021-08-29 01:53:41 +00:00
ATTR_SETTINGS,
DOMAIN,
EXT_X_START_LL_HLS,
EXT_X_START_NON_LL_HLS,
FORMAT_CONTENT_TYPE,
HLS_PROVIDER,
MAX_SEGMENTS,
NUM_PLAYLIST_SEGMENTS,
)
2021-08-29 01:53:41 +00:00
from .core import PROVIDERS, IdleTimer, StreamOutput, StreamSettings, StreamView
from .fmp4utils import get_codec_string
if TYPE_CHECKING:
from . import Stream
2021-08-29 01:53:41 +00:00
_LOGGER = logging.getLogger(__name__)
@callback
def async_setup_hls(hass: HomeAssistant) -> str:
"""Set up api endpoints."""
hass.http.register_view(HlsPlaylistView())
hass.http.register_view(HlsSegmentView())
hass.http.register_view(HlsInitView())
hass.http.register_view(HlsMasterPlaylistView())
return "/api/hls/{}/master_playlist.m3u8"
2021-08-29 01:53:41 +00:00
@PROVIDERS.register(HLS_PROVIDER)
class HlsStreamOutput(StreamOutput):
"""Represents HLS Output formats."""
def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None:
"""Initialize HLS output."""
super().__init__(hass, idle_timer, deque_maxlen=MAX_SEGMENTS)
self.stream_settings: StreamSettings = hass.data[DOMAIN][ATTR_SETTINGS]
self._target_duration = 0.0
@property
def name(self) -> str:
"""Return provider name."""
return HLS_PROVIDER
@property
def target_duration(self) -> float:
"""
Return the target duration.
The target duration is calculated as the max duration of any given segment,
and it is calculated only one time to avoid changing during playback.
"""
if self._target_duration:
return self._target_duration
durations = [s.duration for s in self._segments if s.complete]
if len(durations) < 2:
return self.stream_settings.min_segment_duration
self._target_duration = max(durations)
return self._target_duration
class HlsMasterPlaylistView(StreamView):
"""Stream view used only for Chromecast compatibility."""
url = r"/api/hls/{token:[a-f0-9]+}/master_playlist.m3u8"
name = "api:stream:hls:master_playlist"
cors_allowed = True
@staticmethod
def render(track: StreamOutput) -> str:
"""Render M3U8 file."""
# Need to calculate max bandwidth as input_container.bit_rate doesn't seem to work
# Calculate file size / duration and use a small multiplier to account for variation
# hls spec already allows for 25% variation
if not (segment := track.get_segment(track.sequences[-2])):
return ""
2021-08-29 01:53:41 +00:00
bandwidth = round(segment.data_size_with_init * 8 / segment.duration * 1.2)
codecs = get_codec_string(segment.init)
lines = [
"#EXTM3U",
f'#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},CODECS="{codecs}"',
"playlist.m3u8",
]
return "\n".join(lines) + "\n"
async def handle(
self, request: web.Request, stream: Stream, sequence: str
) -> web.Response:
"""Return m3u8 playlist."""
track = stream.add_provider(HLS_PROVIDER)
stream.start()
# Make sure at least two segments are ready (last one may not be complete)
if not track.sequences and not await track.recv():
return web.HTTPNotFound()
if len(track.sequences) == 1 and not await track.recv():
return web.HTTPNotFound()
2021-08-29 01:53:41 +00:00
response = web.Response(
body=self.render(track).encode("utf-8"),
headers={
"Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER],
},
)
response.enable_compression(web.ContentCoding.gzip)
return response
class HlsPlaylistView(StreamView):
"""Stream view to serve a M3U8 stream."""
2019-07-31 19:25:30 +00:00
url = r"/api/hls/{token:[a-f0-9]+}/playlist.m3u8"
name = "api:stream:hls:playlist"
cors_allowed = True
2021-08-29 01:53:41 +00:00
@classmethod
def render(cls, track: HlsStreamOutput) -> str:
"""Render HLS playlist file."""
# NUM_PLAYLIST_SEGMENTS+1 because most recent is probably not yet complete
segments = list(track.get_segments())[-(NUM_PLAYLIST_SEGMENTS + 1) :]
# To cap the number of complete segments at NUM_PLAYLIST_SEGMENTS,
# remove the first segment if the last segment is actually complete
if segments[-1].complete:
segments = segments[-NUM_PLAYLIST_SEGMENTS:]
first_segment = segments[0]
playlist = [
"#EXTM3U",
"#EXT-X-VERSION:6",
"#EXT-X-INDEPENDENT-SEGMENTS",
'#EXT-X-MAP:URI="init.mp4"',
f"#EXT-X-TARGETDURATION:{track.target_duration:.0f}",
f"#EXT-X-MEDIA-SEQUENCE:{first_segment.sequence}",
f"#EXT-X-DISCONTINUITY-SEQUENCE:{first_segment.stream_id}",
2021-08-29 01:53:41 +00:00
]
if track.stream_settings.ll_hls:
playlist.extend(
[
f"#EXT-X-PART-INF:PART-TARGET={track.stream_settings.part_target_duration:.3f}",
f"#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK={2*track.stream_settings.part_target_duration:.3f}",
f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_LL_HLS*track.stream_settings.part_target_duration:.3f},PRECISE=YES",
]
)
else:
# Since our window doesn't have many segments, we don't want to start
# at the beginning or we risk a behind live window exception in Exoplayer.
# EXT-X-START is not supposed to be within 3 target durations of the end,
# but a value as low as 1.5 doesn't seem to hurt.
# A value below 3 may not be as useful for hls.js as many hls.js clients
# don't autoplay. Also, hls.js uses the player parameter liveSyncDuration
# which seems to take precedence for setting target delay. Yet it also
# doesn't seem to hurt, so we can stick with it for now.
2021-08-29 01:53:41 +00:00
playlist.append(
f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START_NON_LL_HLS*track.target_duration:.3f},PRECISE=YES"
)
last_stream_id = first_segment.stream_id
2021-08-29 01:53:41 +00:00
# Add playlist sections for completed segments
# Enumeration used to only include EXT-X-PART data for last 3 segments.
# The RFC seems to suggest removing parts after 3 full segments, but Apple's
# own example shows removing after 2 full segments and 1 part one.
for i, segment in enumerate(segments[:-1], 3 - len(segments)):
playlist.append(
segment.render_hls(
last_stream_id=last_stream_id,
render_parts=i >= 0 and track.stream_settings.ll_hls,
add_hint=False,
)
2021-08-29 01:53:41 +00:00
)
last_stream_id = segment.stream_id
playlist.append(
segments[-1].render_hls(
last_stream_id=last_stream_id,
render_parts=track.stream_settings.ll_hls,
add_hint=track.stream_settings.ll_hls,
)
)
return "\n".join(playlist) + "\n"
2021-08-29 01:53:41 +00:00
@staticmethod
def bad_request(blocking: bool, target_duration: float) -> web.Response:
"""Return a HTTP Bad Request response."""
return web.Response(
body=None,
status=400,
# From Appendix B.1 of the RFC:
# Successful responses to blocking Playlist requests should be cached
# for six Target Durations. Unsuccessful responses (such as 404s) should
# be cached for four Target Durations. Successful responses to non-blocking
# Playlist requests should be cached for half the Target Duration.
# Unsuccessful responses to non-blocking Playlist requests should be
# cached for for one Target Duration.
headers={
"Cache-Control": f"max-age={(4 if blocking else 1)*target_duration:.0f}"
},
)
@staticmethod
def not_found(blocking: bool, target_duration: float) -> web.Response:
"""Return a HTTP Not Found response."""
return web.Response(
body=None,
status=404,
headers={
"Cache-Control": f"max-age={(4 if blocking else 1)*target_duration:.0f}"
},
)
async def handle(
self, request: web.Request, stream: Stream, sequence: str
) -> web.Response:
"""Return m3u8 playlist."""
2021-08-29 01:53:41 +00:00
track: HlsStreamOutput = cast(
HlsStreamOutput, stream.add_provider(HLS_PROVIDER)
)
stream.start()
2021-08-29 01:53:41 +00:00
hls_msn: str | int | None = request.query.get("_HLS_msn")
hls_part: str | int | None = request.query.get("_HLS_part")
blocking_request = bool(hls_msn or hls_part)
# If the Playlist URI contains an _HLS_part directive but no _HLS_msn
# directive, the Server MUST return Bad Request, such as HTTP 400.
if hls_msn is None and hls_part:
return web.HTTPBadRequest()
hls_msn = int(hls_msn or 0)
# If the _HLS_msn is greater than the Media Sequence Number of the last
# Media Segment in the current Playlist plus two, or if the _HLS_part
# exceeds the last Part Segment in the current Playlist by the
# Advance Part Limit, then the server SHOULD immediately return Bad
# Request, such as HTTP 400.
if hls_msn > track.last_sequence + 2:
return self.bad_request(blocking_request, track.target_duration)
if hls_part is None:
# We need to wait for the whole segment, so effectively the next msn
hls_part = -1
hls_msn += 1
else:
hls_part = int(hls_part)
while hls_msn > track.last_sequence:
if not await track.recv():
return self.not_found(blocking_request, track.target_duration)
if track.last_segment is None:
return self.not_found(blocking_request, 0)
if (
(last_segment := track.last_segment)
and hls_msn == last_segment.sequence
and hls_part
>= len(last_segment.parts_by_byterange)
- 1
+ track.stream_settings.hls_advance_part_limit
):
return self.bad_request(blocking_request, track.target_duration)
# Receive parts until msn and part are met
while (
(last_segment := track.last_segment)
and hls_msn == last_segment.sequence
and hls_part >= len(last_segment.parts_by_byterange)
):
if not await track.part_recv(
timeout=track.stream_settings.hls_part_timeout
):
return self.not_found(blocking_request, track.target_duration)
# Now we should have msn.part >= hls_msn.hls_part. However, in the case
# that we have a rollover part request from the previous segment, we need
# to make sure that the new segment has a part. From 6.2.5.2 of the RFC:
# If the Client requests a Part Index greater than that of the final
# Partial Segment of the Parent Segment, the Server MUST treat the
# request as one for Part Index 0 of the following Parent Segment.
if hls_msn + 1 == last_segment.sequence:
if not (previous_segment := track.get_segment(hls_msn)) or (
hls_part >= len(previous_segment.parts_by_byterange)
and not last_segment.parts_by_byterange
and not await track.part_recv(
timeout=track.stream_settings.hls_part_timeout
)
):
return self.not_found(blocking_request, track.target_duration)
response = web.Response(
2021-08-29 01:53:41 +00:00
body=self.render(track).encode("utf-8"),
headers={
"Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER],
"Cache-Control": f"max-age={(6 if blocking_request else 0.5)*track.target_duration:.0f}",
},
)
response.enable_compression(web.ContentCoding.gzip)
return response
class HlsInitView(StreamView):
"""Stream view to serve HLS init.mp4."""
url = r"/api/hls/{token:[a-f0-9]+}/init.mp4"
name = "api:stream:hls:init"
cors_allowed = True
async def handle(
self, request: web.Request, stream: Stream, sequence: str
) -> web.Response:
"""Return init.mp4."""
track = stream.add_provider(HLS_PROVIDER)
2021-08-29 01:53:41 +00:00
if not (segments := track.get_segments()) or not (body := segments[0].init):
return web.HTTPNotFound()
return web.Response(
2021-08-29 01:53:41 +00:00
body=body,
headers={"Content-Type": "video/mp4"},
)
class HlsSegmentView(StreamView):
"""Stream view to serve a HLS fmp4 segment."""
url = r"/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.m4s"
2019-07-31 19:25:30 +00:00
name = "api:stream:hls:segment"
cors_allowed = True
async def handle(
self, request: web.Request, stream: Stream, sequence: str
2021-08-29 01:53:41 +00:00
) -> web.StreamResponse:
"""Handle segments, part segments, and hinted segments.
For part and hinted segments, the start of the requested range must align
with a part boundary.
"""
track: HlsStreamOutput = cast(
HlsStreamOutput, stream.add_provider(HLS_PROVIDER)
)
2021-08-29 01:53:41 +00:00
track.idle_timer.awake()
# Ensure that we have a segment. If the request is from a hint for part 0
# of a segment, there is a small chance it may have arrived before the
# segment has been put. If this happens, wait for one part and retry.
if not (
(segment := track.get_segment(int(sequence)))
or (
await track.part_recv(timeout=track.stream_settings.hls_part_timeout)
and (segment := track.get_segment(int(sequence)))
)
):
return web.Response(
body=None,
status=404,
headers={"Cache-Control": f"max-age={track.target_duration:.0f}"},
)
# If the segment is ready or has been hinted, the http_range start should be at most
# equal to the end of the currently available data.
# If the segment is complete, the http_range start should be less than the end of the
# currently available data.
# If these conditions aren't met then we return a 416.
# http_range_start can be None, so use a copy that uses 0 instead of None
if (http_start := request.http_range.start or 0) > segment.data_size or (
segment.complete and http_start >= segment.data_size
):
return web.HTTPRequestRangeNotSatisfiable(
headers={
"Cache-Control": f"max-age={track.target_duration:.0f}",
"Content-Range": f"bytes */{segment.data_size}",
}
)
headers = {
"Content-Type": "video/iso.segment",
"Cache-Control": f"max-age={6*track.target_duration:.0f}",
}
# For most cases we have a 206 partial content response.
status = 206
# For the 206 responses we need to set a Content-Range header
# See https://datatracker.ietf.org/doc/html/rfc8673#section-2
if request.http_range.stop is None:
if request.http_range.start is None:
status = 200
if segment.complete:
# This is a request for a full segment which is already complete
# We should return a standard 200 response.
return web.Response(
body=segment.get_data(), headers=headers, status=status
)
# Otherwise we still return a 200 response, but it is aggregating
http_stop = float("inf")
else:
# See https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
headers[
"Content-Range"
] = f"bytes {http_start}-{(http_stop:=segment.data_size)-1}/*"
else: # The remaining cases are all 206 responses
if segment.complete:
# If the segment is complete we have total size
headers["Content-Range"] = (
f"bytes {http_start}-"
+ str(
(http_stop := min(request.http_range.stop, segment.data_size))
- 1
)
+ f"/{segment.data_size}"
)
else:
# If we don't have the total size we use a *
headers[
"Content-Range"
] = f"bytes {http_start}-{(http_stop:=request.http_range.stop)-1}/*"
# Set up streaming response that we can write to as data becomes available
response = web.StreamResponse(headers=headers, status=status)
# Waiting until we write to prepare *might* give clients more accurate TTFB
# and ABR measurements, but it is probably not very useful for us since we
# only have one rendition anyway. Just prepare here for now.
await response.prepare(request)
try:
for bytes_to_write in segment.get_aggregating_bytes(
start_loc=http_start, end_loc=http_stop
):
if bytes_to_write:
await response.write(bytes_to_write)
elif not await track.part_recv(
timeout=track.stream_settings.hls_part_timeout
):
break
except ConnectionResetError:
_LOGGER.warning("Connection reset while serving HLS partial segment")
return response