mirror of
https://github.com/home-assistant/core
synced 2024-08-28 03:36:46 +02:00
433 lines
16 KiB
Python
433 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",
|
|
},
|
|
)
|