core/homeassistant/components/stream/hls.py

434 lines
16 KiB
Python

"""Provide functionality to stream HLS."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING, cast
from aiohttp import web
from homeassistant.core import HomeAssistant, callback
from .const import (
EXT_X_START_LL_HLS,
EXT_X_START_NON_LL_HLS,
FORMAT_CONTENT_TYPE,
HLS_PROVIDER,
MAX_SEGMENTS,
NUM_PLAYLIST_SEGMENTS,
)
from .core import (
PROVIDERS,
IdleTimer,
Segment,
StreamOutput,
StreamSettings,
StreamView,
)
from .fmp4utils import get_codec_string, transform_init
if TYPE_CHECKING:
from homeassistant.components.camera import DynamicStreamSettings
from . import Stream
@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())
hass.http.register_view(HlsPartView())
return "/api/hls/{}/master_playlist.m3u8"
@PROVIDERS.register(HLS_PROVIDER)
class HlsStreamOutput(StreamOutput):
"""Represents HLS Output formats."""
def __init__(
self,
hass: HomeAssistant,
idle_timer: IdleTimer,
stream_settings: StreamSettings,
dynamic_stream_settings: DynamicStreamSettings,
) -> None:
"""Initialize HLS output."""
super().__init__(
hass,
idle_timer,
stream_settings,
dynamic_stream_settings,
deque_maxlen=MAX_SEGMENTS,
)
self._target_duration = stream_settings.min_segment_duration
@property
def name(self) -> str:
"""Return provider name."""
return HLS_PROVIDER
def cleanup(self) -> None:
"""Handle cleanup."""
super().cleanup()
self._segments.clear()
@property
def target_duration(self) -> float:
"""Return the target duration."""
return self._target_duration
@callback
def _async_put(self, segment: Segment) -> None:
"""Async put and also update the target duration.
The target duration is calculated as the max duration of any given segment.
Technically it should not change per the hls spec, but some cameras adjust
their GOPs periodically so we need to account for this change.
"""
super()._async_put(segment)
self._target_duration = (
max((s.duration for s in self._segments), default=segment.duration)
or self.stream_settings.min_segment_duration
)
def discontinuity(self) -> None:
"""Fix incomplete segment at end of deque."""
self._hass.loop.call_soon_threadsafe(self._async_discontinuity)
@callback
def _async_discontinuity(self) -> None:
"""Fix incomplete segment at end of deque in event loop."""
# Fill in the segment duration or delete the segment if empty
if self._segments:
if (last_segment := self._segments[-1]).parts:
last_segment.duration = sum(
part.duration for part in last_segment.parts
)
else:
self._segments.pop()
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 ""
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, part_num: str
) -> web.Response:
"""Return m3u8 playlist."""
track = stream.add_provider(HLS_PROVIDER)
await 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()
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."""
url = r"/api/hls/{token:[a-f0-9]+}/playlist.m3u8"
name = "api:stream:hls:playlist"
cors_allowed = True
@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}",
]
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.
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
# 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,
)
)
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"
@staticmethod
def bad_request(blocking: bool, target_duration: float) -> web.Response:
"""Return a HTTP Bad Request response."""
return web.Response(
body=None,
status=HTTPStatus.BAD_REQUEST,
)
@staticmethod
def not_found(blocking: bool, target_duration: float) -> web.Response:
"""Return a HTTP Not Found response."""
return web.Response(
body=None,
status=HTTPStatus.NOT_FOUND,
)
async def handle(
self, request: web.Request, stream: Stream, sequence: str, part_num: str
) -> web.Response:
"""Return m3u8 playlist."""
track: HlsStreamOutput = cast(
HlsStreamOutput, stream.add_provider(HLS_PROVIDER)
)
await stream.start()
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)
- 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)
):
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)
and not last_segment.parts
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(
body=self.render(track).encode("utf-8"),
headers={
"Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER],
},
)
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, part_num: str
) -> web.Response:
"""Return init.mp4."""
track = stream.add_provider(HLS_PROVIDER)
if not (segments := track.get_segments()) or not (body := segments[0].init):
return web.HTTPNotFound()
return web.Response(
body=transform_init(body, stream.dynamic_stream_settings.orientation),
headers={"Content-Type": "video/mp4"},
)
class HlsPartView(StreamView):
"""Stream view to serve a HLS fmp4 segment."""
url = r"/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.{part_num:\d+}.m4s"
name = "api:stream:hls:part"
cors_allowed = True
async def handle(
self, request: web.Request, stream: Stream, sequence: str, part_num: str
) -> web.Response:
"""Handle part."""
track: HlsStreamOutput = cast(
HlsStreamOutput, stream.add_provider(HLS_PROVIDER)
)
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=HTTPStatus.NOT_FOUND,
)
# If the part is ready or has been hinted,
if int(part_num) == len(segment.parts):
await track.part_recv(timeout=track.stream_settings.hls_part_timeout)
if int(part_num) >= len(segment.parts):
return web.HTTPRequestRangeNotSatisfiable()
return web.Response(
body=segment.parts[int(part_num)].data,
headers={
"Content-Type": "video/iso.segment",
},
)
class HlsSegmentView(StreamView):
"""Stream view to serve a HLS fmp4 segment."""
url = r"/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.m4s"
name = "api:stream:hls:segment"
cors_allowed = True
async def handle(
self, request: web.Request, stream: Stream, sequence: str, part_num: str
) -> web.StreamResponse:
"""Handle segments."""
track: HlsStreamOutput = cast(
HlsStreamOutput, stream.add_provider(HLS_PROVIDER)
)
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=HTTPStatus.NOT_FOUND,
)
return web.Response(
body=segment.get_data(),
headers={
"Content-Type": "video/iso.segment",
},
)