2019-04-03 15:40:03 +00:00
|
|
|
"""Provide functionality to stream HLS."""
|
2021-06-14 15:59:25 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
2019-03-12 02:57:10 +00:00
|
|
|
from aiohttp import web
|
|
|
|
|
2021-06-14 15:59:25 +00:00
|
|
|
from homeassistant.core import HomeAssistant, callback
|
2019-03-12 02:57:10 +00:00
|
|
|
|
2021-05-27 03:22:31 +00:00
|
|
|
from .const import (
|
2021-05-30 03:41:23 +00:00
|
|
|
EXT_X_START,
|
2021-05-27 03:22:31 +00:00
|
|
|
FORMAT_CONTENT_TYPE,
|
|
|
|
HLS_PROVIDER,
|
|
|
|
MAX_SEGMENTS,
|
|
|
|
NUM_PLAYLIST_SEGMENTS,
|
|
|
|
)
|
2021-06-14 15:59:25 +00:00
|
|
|
from .core import PROVIDERS, IdleTimer, StreamOutput, StreamView
|
2021-05-26 08:19:09 +00:00
|
|
|
from .fmp4utils import get_codec_string
|
2019-03-12 02:57:10 +00:00
|
|
|
|
2021-06-14 15:59:25 +00:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
from . import Stream
|
|
|
|
|
2019-03-12 02:57:10 +00:00
|
|
|
|
|
|
|
@callback
|
2021-06-14 15:59:25 +00:00
|
|
|
def async_setup_hls(hass: HomeAssistant) -> str:
|
2019-03-12 02:57:10 +00:00
|
|
|
"""Set up api endpoints."""
|
|
|
|
hass.http.register_view(HlsPlaylistView())
|
|
|
|
hass.http.register_view(HlsSegmentView())
|
2020-08-11 21:12:41 +00:00
|
|
|
hass.http.register_view(HlsInitView())
|
2020-09-27 20:38:14 +00:00
|
|
|
hass.http.register_view(HlsMasterPlaylistView())
|
|
|
|
return "/api/hls/{}/master_playlist.m3u8"
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2021-06-14 15:59:25 +00:00
|
|
|
def render(track: StreamOutput) -> str:
|
2020-09-27 20:38:14 +00:00
|
|
|
"""Render M3U8 file."""
|
|
|
|
# Need to calculate max bandwidth as input_container.bit_rate doesn't seem to work
|
2020-10-15 20:37:27 +00:00
|
|
|
# Calculate file size / duration and use a small multiplier to account for variation
|
|
|
|
# hls spec already allows for 25% variation
|
2021-06-14 15:59:25 +00:00
|
|
|
if not (segment := track.get_segment(track.sequences[-2])):
|
|
|
|
return ""
|
2020-09-27 20:38:14 +00:00
|
|
|
bandwidth = round(
|
2021-06-13 16:41:21 +00:00
|
|
|
(len(segment.init) + sum(len(part.data) for part in segment.parts))
|
|
|
|
* 8
|
|
|
|
/ segment.duration
|
|
|
|
* 1.2
|
2020-09-27 20:38:14 +00:00
|
|
|
)
|
2021-05-26 08:19:09 +00:00
|
|
|
codecs = get_codec_string(segment.init)
|
2020-09-27 20:38:14 +00:00
|
|
|
lines = [
|
|
|
|
"#EXTM3U",
|
|
|
|
f'#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},CODECS="{codecs}"',
|
|
|
|
"playlist.m3u8",
|
|
|
|
]
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
2021-06-14 15:59:25 +00:00
|
|
|
async def handle(
|
|
|
|
self, request: web.Request, stream: Stream, sequence: str
|
|
|
|
) -> web.Response:
|
2020-09-27 20:38:14 +00:00
|
|
|
"""Return m3u8 playlist."""
|
2021-05-27 03:22:31 +00:00
|
|
|
track = stream.add_provider(HLS_PROVIDER)
|
2021-02-20 14:49:39 +00:00
|
|
|
stream.start()
|
2021-06-13 16:41:21 +00:00
|
|
|
# Make sure at least two segments are ready (last one may not be complete)
|
2021-05-28 05:36:41 +00:00
|
|
|
if not track.sequences and not await track.recv():
|
2021-03-27 09:54:59 +00:00
|
|
|
return web.HTTPNotFound()
|
2021-06-13 16:41:21 +00:00
|
|
|
if len(track.sequences) == 1 and not await track.recv():
|
|
|
|
return web.HTTPNotFound()
|
2021-05-27 03:22:31 +00:00
|
|
|
headers = {"Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER]}
|
2020-09-27 20:38:14 +00:00
|
|
|
return web.Response(body=self.render(track).encode("utf-8"), headers=headers)
|
2019-03-12 02:57:10 +00:00
|
|
|
|
|
|
|
|
|
|
|
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"
|
2019-03-12 02:57:10 +00:00
|
|
|
cors_allowed = True
|
|
|
|
|
2020-09-27 20:38:14 +00:00
|
|
|
@staticmethod
|
2021-06-14 15:59:25 +00:00
|
|
|
def render(track: StreamOutput) -> str:
|
2020-09-27 20:38:14 +00:00
|
|
|
"""Render playlist."""
|
2021-06-13 16:41:21 +00:00
|
|
|
# NUM_PLAYLIST_SEGMENTS+1 because most recent is probably not yet complete
|
|
|
|
segments = list(track.get_segments())[-(NUM_PLAYLIST_SEGMENTS + 1) :]
|
2020-09-27 20:38:14 +00:00
|
|
|
|
2021-06-13 16:41:21 +00:00
|
|
|
# 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:]
|
2020-09-27 20:38:14 +00:00
|
|
|
|
2021-05-30 03:41:23 +00:00
|
|
|
first_segment = segments[0]
|
2021-02-18 12:26:02 +00:00
|
|
|
playlist = [
|
2021-06-13 16:41:21 +00:00
|
|
|
"#EXTM3U",
|
|
|
|
"#EXT-X-VERSION:6",
|
|
|
|
"#EXT-X-INDEPENDENT-SEGMENTS",
|
|
|
|
'#EXT-X-MAP:URI="init.mp4"',
|
|
|
|
f"#EXT-X-TARGETDURATION:{track.target_duration:.0f}",
|
2021-05-30 03:41:23 +00:00
|
|
|
f"#EXT-X-MEDIA-SEQUENCE:{first_segment.sequence}",
|
|
|
|
f"#EXT-X-DISCONTINUITY-SEQUENCE:{first_segment.stream_id}",
|
|
|
|
"#EXT-X-PROGRAM-DATE-TIME:"
|
|
|
|
+ first_segment.start_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
|
|
+ "Z",
|
|
|
|
# Since our window doesn't have many segments, we don't want to start
|
2021-06-13 16:41:21 +00:00
|
|
|
# at the beginning or we risk a behind live window exception in Exoplayer.
|
2021-05-30 03:41:23 +00:00
|
|
|
# EXT-X-START is not supposed to be within 3 target durations of the end,
|
2021-06-13 16:41:21 +00:00
|
|
|
# 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.
|
|
|
|
f"#EXT-X-START:TIME-OFFSET=-{EXT_X_START * track.target_duration:.3f}",
|
2021-02-18 12:26:02 +00:00
|
|
|
]
|
2020-09-27 20:38:14 +00:00
|
|
|
|
2021-05-30 03:41:23 +00:00
|
|
|
last_stream_id = first_segment.stream_id
|
2021-06-13 16:41:21 +00:00
|
|
|
# Add playlist sections
|
2021-02-18 12:26:02 +00:00
|
|
|
for segment in segments:
|
2021-06-13 16:41:21 +00:00
|
|
|
# Skip last segment if it is not complete
|
|
|
|
if segment.complete:
|
|
|
|
if last_stream_id != segment.stream_id:
|
|
|
|
playlist.extend(
|
|
|
|
[
|
|
|
|
"#EXT-X-DISCONTINUITY",
|
|
|
|
"#EXT-X-PROGRAM-DATE-TIME:"
|
|
|
|
+ segment.start_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
|
|
+ "Z",
|
|
|
|
]
|
|
|
|
)
|
2021-05-30 03:41:23 +00:00
|
|
|
playlist.extend(
|
|
|
|
[
|
2021-06-13 16:41:21 +00:00
|
|
|
f"#EXTINF:{segment.duration:.3f},",
|
|
|
|
f"./segment/{segment.sequence}.m4s",
|
2021-05-30 03:41:23 +00:00
|
|
|
]
|
|
|
|
)
|
2021-06-13 16:41:21 +00:00
|
|
|
last_stream_id = segment.stream_id
|
2020-09-27 20:38:14 +00:00
|
|
|
|
2021-06-13 16:41:21 +00:00
|
|
|
return "\n".join(playlist) + "\n"
|
2020-09-27 20:38:14 +00:00
|
|
|
|
2021-06-14 15:59:25 +00:00
|
|
|
async def handle(
|
|
|
|
self, request: web.Request, stream: Stream, sequence: str
|
|
|
|
) -> web.Response:
|
2019-03-12 02:57:10 +00:00
|
|
|
"""Return m3u8 playlist."""
|
2021-05-27 03:22:31 +00:00
|
|
|
track = stream.add_provider(HLS_PROVIDER)
|
2021-02-20 14:49:39 +00:00
|
|
|
stream.start()
|
2021-06-13 16:41:21 +00:00
|
|
|
# Make sure at least two segments are ready (last one may not be complete)
|
2021-05-28 05:36:41 +00:00
|
|
|
if not track.sequences and not await track.recv():
|
2021-03-27 09:54:59 +00:00
|
|
|
return web.HTTPNotFound()
|
2021-06-13 16:41:21 +00:00
|
|
|
if len(track.sequences) == 1 and not await track.recv():
|
|
|
|
return web.HTTPNotFound()
|
2021-05-27 03:22:31 +00:00
|
|
|
headers = {"Content-Type": FORMAT_CONTENT_TYPE[HLS_PROVIDER]}
|
2021-05-30 03:41:23 +00:00
|
|
|
response = web.Response(
|
|
|
|
body=self.render(track).encode("utf-8"), headers=headers
|
|
|
|
)
|
|
|
|
response.enable_compression(web.ContentCoding.gzip)
|
|
|
|
return response
|
2019-03-12 02:57:10 +00:00
|
|
|
|
|
|
|
|
2020-08-11 21:12:41 +00:00
|
|
|
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
|
|
|
|
|
2021-06-14 15:59:25 +00:00
|
|
|
async def handle(
|
|
|
|
self, request: web.Request, stream: Stream, sequence: str
|
|
|
|
) -> web.Response:
|
2020-08-11 21:12:41 +00:00
|
|
|
"""Return init.mp4."""
|
2021-05-27 03:22:31 +00:00
|
|
|
track = stream.add_provider(HLS_PROVIDER)
|
2021-05-30 03:41:23 +00:00
|
|
|
if not (segments := track.get_segments()):
|
2020-08-11 21:12:41 +00:00
|
|
|
return web.HTTPNotFound()
|
|
|
|
headers = {"Content-Type": "video/mp4"}
|
2021-05-26 08:19:09 +00:00
|
|
|
return web.Response(body=segments[0].init, headers=headers)
|
2020-08-11 21:12:41 +00:00
|
|
|
|
|
|
|
|
2019-03-12 02:57:10 +00:00
|
|
|
class HlsSegmentView(StreamView):
|
2020-08-11 21:12:41 +00:00
|
|
|
"""Stream view to serve a HLS fmp4 segment."""
|
2019-03-12 02:57:10 +00:00
|
|
|
|
2020-08-11 21:12:41 +00:00
|
|
|
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"
|
2019-03-12 02:57:10 +00:00
|
|
|
cors_allowed = True
|
|
|
|
|
2021-06-14 15:59:25 +00:00
|
|
|
async def handle(
|
|
|
|
self, request: web.Request, stream: Stream, sequence: str
|
|
|
|
) -> web.Response:
|
2020-08-11 21:12:41 +00:00
|
|
|
"""Return fmp4 segment."""
|
2021-05-27 03:22:31 +00:00
|
|
|
track = stream.add_provider(HLS_PROVIDER)
|
2021-06-03 04:31:39 +00:00
|
|
|
track.idle_timer.awake()
|
2021-05-30 03:41:23 +00:00
|
|
|
if not (segment := track.get_segment(int(sequence))):
|
2019-03-12 02:57:10 +00:00
|
|
|
return web.HTTPNotFound()
|
2020-08-11 21:12:41 +00:00
|
|
|
headers = {"Content-Type": "video/iso.segment"}
|
|
|
|
return web.Response(
|
2021-06-13 16:41:21 +00:00
|
|
|
body=segment.get_bytes_without_init(),
|
2020-08-27 11:56:20 +00:00
|
|
|
headers=headers,
|
2020-08-11 21:12:41 +00:00
|
|
|
)
|
2019-03-12 02:57:10 +00:00
|
|
|
|
|
|
|
|
2021-05-27 03:22:31 +00:00
|
|
|
@PROVIDERS.register(HLS_PROVIDER)
|
2019-03-12 02:57:10 +00:00
|
|
|
class HlsStreamOutput(StreamOutput):
|
|
|
|
"""Represents HLS Output formats."""
|
|
|
|
|
2021-02-23 02:37:19 +00:00
|
|
|
def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None:
|
|
|
|
"""Initialize recorder output."""
|
|
|
|
super().__init__(hass, idle_timer, deque_maxlen=MAX_SEGMENTS)
|
|
|
|
|
2021-02-20 14:49:39 +00:00
|
|
|
@property
|
|
|
|
def name(self) -> str:
|
|
|
|
"""Return provider name."""
|
2021-05-27 03:22:31 +00:00
|
|
|
return HLS_PROVIDER
|