413 lines
14 KiB
Python
413 lines
14 KiB
Python
|
"""Reolink Camera Media Source Implementation."""
|
||
|
from urllib import parse
|
||
|
import secrets
|
||
|
import datetime as dt
|
||
|
import logging
|
||
|
from typing import Optional, Tuple
|
||
|
from aiohttp import web
|
||
|
from haffmpeg.tools import IMAGE_JPEG
|
||
|
|
||
|
from dateutil import relativedelta
|
||
|
|
||
|
from homeassistant.core import HomeAssistant, callback
|
||
|
|
||
|
import homeassistant.util.dt as dt_utils
|
||
|
|
||
|
from homeassistant.components.http import HomeAssistantView
|
||
|
|
||
|
# from homeassistant.components.http.auth import async_sign_path
|
||
|
from homeassistant.components.media_player.errors import BrowseError
|
||
|
from homeassistant.components.media_player.const import (
|
||
|
MEDIA_CLASS_DIRECTORY,
|
||
|
MEDIA_CLASS_VIDEO,
|
||
|
MEDIA_TYPE_VIDEO,
|
||
|
)
|
||
|
from homeassistant.components.media_source.const import MEDIA_MIME_TYPES
|
||
|
from homeassistant.components.media_source.error import MediaSourceError, Unresolvable
|
||
|
from homeassistant.components.media_source.models import (
|
||
|
BrowseMediaSource,
|
||
|
MediaSource,
|
||
|
MediaSourceItem,
|
||
|
PlayMedia,
|
||
|
)
|
||
|
|
||
|
from homeassistant.components.stream import create_stream
|
||
|
from homeassistant.components.ffmpeg import async_get_image
|
||
|
|
||
|
from custom_components.reolink_dev.base import ReolinkBase
|
||
|
|
||
|
from . import typings
|
||
|
|
||
|
from .const import BASE, DEFAULT_THUMBNAIL_OFFSET, DOMAIN
|
||
|
|
||
|
_LOGGER = logging.getLogger(__name__)
|
||
|
# MIME_TYPE = "rtmp/mp4"
|
||
|
# MIME_TYPE = "video/mp4"
|
||
|
MIME_TYPE = "application/x-mpegURL"
|
||
|
|
||
|
NAME = "Reolink IP Camera"
|
||
|
|
||
|
|
||
|
class IncompatibleMediaSource(MediaSourceError):
|
||
|
"""Incompatible media source attributes."""
|
||
|
|
||
|
|
||
|
async def async_get_media_source(hass: HomeAssistant):
|
||
|
"""Set up Reolink media source."""
|
||
|
_LOGGER.debug("Creating REOLink Media Source")
|
||
|
source = ReolinkSource(hass)
|
||
|
hass.http.register_view(ReolinkSourceThumbnailView(hass, source))
|
||
|
return source
|
||
|
|
||
|
|
||
|
class ReolinkSource(MediaSource):
|
||
|
"""Provide Reolink camera recordings as media sources."""
|
||
|
|
||
|
name: str = NAME
|
||
|
|
||
|
def __init__(self, hass: HomeAssistant):
|
||
|
"""Initialize Reolink source."""
|
||
|
super().__init__(DOMAIN)
|
||
|
self.hass = hass
|
||
|
self.cache = {}
|
||
|
|
||
|
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||
|
"""Resolve a media item to a playable item."""
|
||
|
_, camera_id, event_id = async_parse_identifier(item)
|
||
|
cache: typings.MediaSourceCacheEntry = self.cache[camera_id]
|
||
|
event = cache["playback_events"][event_id]
|
||
|
base: ReolinkBase = self.hass.data[DOMAIN][cache["entry_id"]][BASE]
|
||
|
url = await base.api.get_vod_source(event["file"])
|
||
|
_LOGGER.debug("Load VOD %s", url)
|
||
|
stream = create_stream(self.hass, url)
|
||
|
stream.add_provider("hls", timeout=600)
|
||
|
url: str = stream.endpoint_url("hls")
|
||
|
# the media browser seems to have a problem with the master_playlist
|
||
|
# ( it does not load the referenced playlist ) so we will just
|
||
|
# force the reference playlist instead, this seems to work
|
||
|
# though technically wrong
|
||
|
url = url.replace("master_", "")
|
||
|
_LOGGER.debug("Proxy %s", url)
|
||
|
return PlayMedia(url, MIME_TYPE)
|
||
|
|
||
|
async def async_browse_media(
|
||
|
self, item: MediaSourceItem, media_types: Tuple[str] = MEDIA_MIME_TYPES
|
||
|
) -> BrowseMediaSource:
|
||
|
"""Browse media."""
|
||
|
|
||
|
try:
|
||
|
source, camera_id, event_id = async_parse_identifier(item)
|
||
|
except Unresolvable as err:
|
||
|
raise BrowseError(str(err)) from err
|
||
|
|
||
|
_LOGGER.debug("Browsing %s, %s, %s", source, camera_id, event_id)
|
||
|
|
||
|
if camera_id and camera_id not in self.cache:
|
||
|
raise BrowseError("Camera does not exist.")
|
||
|
|
||
|
if (
|
||
|
event_id
|
||
|
and not "/" in event_id
|
||
|
and event_id not in self.cache[camera_id]["playback_events"]
|
||
|
):
|
||
|
raise BrowseError("Event does not exist.")
|
||
|
|
||
|
return await self._async_browse_media(source, camera_id, event_id, False)
|
||
|
|
||
|
async def _async_browse_media(
|
||
|
self, source: str, camera_id: str, event_id: str = None, no_descend: bool = True
|
||
|
) -> BrowseMediaSource:
|
||
|
""" actual browse after input validation """
|
||
|
event: typings.VodEvent = None
|
||
|
cache: typings.MediaSourceCacheEntry = None
|
||
|
start_date = None
|
||
|
|
||
|
if camera_id and camera_id in self.cache:
|
||
|
cache = self.cache[camera_id]
|
||
|
|
||
|
if cache and event_id:
|
||
|
if "playback_events" in cache and event_id in cache["playback_events"]:
|
||
|
event = cache["playback_events"][event_id]
|
||
|
end_date = event["end"]
|
||
|
start_date = event["start"]
|
||
|
time = start_date.time()
|
||
|
duration = end_date - start_date
|
||
|
|
||
|
title = f"{time} {duration}"
|
||
|
else:
|
||
|
year, *rest = event_id.split("/", 3)
|
||
|
month = rest[0] if len(rest) > 0 else None
|
||
|
day = rest[1] if len(rest) > 1 else None
|
||
|
|
||
|
start_date = dt.datetime.combine(
|
||
|
dt.date(
|
||
|
int(year), int(month) if month else 1, int(day) if day else 1
|
||
|
),
|
||
|
dt.time.min,
|
||
|
dt_utils.now().tzinfo,
|
||
|
)
|
||
|
|
||
|
title = f"{start_date.date()}"
|
||
|
|
||
|
path = f"{source}/{camera_id}/{event_id}"
|
||
|
else:
|
||
|
if cache is None:
|
||
|
camera_id = ""
|
||
|
title = NAME
|
||
|
else:
|
||
|
title = cache["name"]
|
||
|
|
||
|
path = f"{source}/{camera_id}"
|
||
|
|
||
|
media_class = MEDIA_CLASS_DIRECTORY if event is None else MEDIA_CLASS_VIDEO
|
||
|
|
||
|
media = BrowseMediaSource(
|
||
|
domain=DOMAIN,
|
||
|
identifier=path,
|
||
|
media_class=media_class,
|
||
|
media_content_type=MEDIA_TYPE_VIDEO,
|
||
|
title=title,
|
||
|
can_play=bool(not event is None and event.get("file")),
|
||
|
can_expand=event is None,
|
||
|
)
|
||
|
|
||
|
if not event is None and cache.get("playback_thumbnails", False):
|
||
|
url = "/api/" + DOMAIN + f"/media_proxy/{camera_id}/{event_id}"
|
||
|
|
||
|
# TODO : I cannot find a way to get the current user context at this point
|
||
|
# so I will have to leave the view as unauthenticated, as a temporary
|
||
|
# security measure, I will add a unique token to the event to limit
|
||
|
# "exposure"
|
||
|
# url = async_sign_path(self.hass, None, url, dt.timedelta(minutes=30))
|
||
|
if "token" not in event:
|
||
|
event["token"] = secrets.token_hex()
|
||
|
media.thumbnail = f"{url}?token={parse.quote_plus(event['token'])}"
|
||
|
|
||
|
if not media.can_play and not media.can_expand:
|
||
|
_LOGGER.debug(
|
||
|
"Camera %s with event %s without media url found", camera_id, event_id
|
||
|
)
|
||
|
raise IncompatibleMediaSource
|
||
|
|
||
|
if not media.can_expand or no_descend:
|
||
|
return media
|
||
|
|
||
|
media.children = []
|
||
|
|
||
|
base: ReolinkBase = None
|
||
|
|
||
|
if cache is None:
|
||
|
for entry_id in self.hass.data[DOMAIN]:
|
||
|
entry = self.hass.data[DOMAIN][entry_id]
|
||
|
if not isinstance(entry, dict) or not BASE in entry:
|
||
|
continue
|
||
|
base = entry[BASE]
|
||
|
camera_id = base.unique_id
|
||
|
cache = self.cache.get(camera_id, None)
|
||
|
if cache is None:
|
||
|
cache = self.cache[camera_id] = {
|
||
|
"entry_id": entry_id,
|
||
|
"unique_id": base.unique_id,
|
||
|
"playback_events": {},
|
||
|
}
|
||
|
cache["name"] = base.name
|
||
|
|
||
|
child = await self._async_browse_media(source, camera_id)
|
||
|
media.children.append(child)
|
||
|
return media
|
||
|
|
||
|
base = self.hass.data[DOMAIN][cache["entry_id"]][BASE]
|
||
|
|
||
|
# TODO: the cache is one way so over time it can grow and have invalid
|
||
|
# records, the code should be expanded to invalidate/expire
|
||
|
# entries
|
||
|
|
||
|
if base is None:
|
||
|
raise BrowseError("Camera does not exist.")
|
||
|
|
||
|
if not start_date:
|
||
|
if (
|
||
|
"playback_day_entries" not in cache
|
||
|
or cache.get("playback_months", -1) != base.playback_months
|
||
|
):
|
||
|
end_date = dt_utils.now()
|
||
|
start_date = dt.datetime.combine(end_date.date(), dt.time.min)
|
||
|
cache["playback_months"] = base.playback_months
|
||
|
if cache["playback_months"] > 1:
|
||
|
start_date -= relativedelta.relativedelta(
|
||
|
months=int(cache["playback_months"])
|
||
|
)
|
||
|
|
||
|
entries = cache["playback_day_entries"] = []
|
||
|
|
||
|
search, _ = await base.api.send_search(start_date, end_date, True)
|
||
|
|
||
|
if not search is None:
|
||
|
for status in search:
|
||
|
year = status["year"]
|
||
|
month = status["mon"]
|
||
|
for day, flag in enumerate(status["table"], start=1):
|
||
|
if flag == "1":
|
||
|
entries.append(dt.date(year, month, day))
|
||
|
|
||
|
entries.sort()
|
||
|
else:
|
||
|
entries = cache["playback_day_entries"]
|
||
|
|
||
|
for date in cache["playback_day_entries"]:
|
||
|
child = await self._async_browse_media(
|
||
|
source, camera_id, f"{date.year}/{date.month}/{date.day}"
|
||
|
)
|
||
|
media.children.append(child)
|
||
|
|
||
|
return media
|
||
|
|
||
|
cache["playback_thumbnails"] = base.playback_thumbnails
|
||
|
|
||
|
end_date = dt.datetime.combine(
|
||
|
start_date.date(), dt.time.max, start_date.tzinfo
|
||
|
)
|
||
|
|
||
|
_, files = await base.api.send_search(start_date, end_date)
|
||
|
|
||
|
if not files is None:
|
||
|
events = cache.setdefault("playback_events", {})
|
||
|
|
||
|
for file in files:
|
||
|
dto = file["EndTime"]
|
||
|
end_date = dt.datetime(
|
||
|
dto["year"],
|
||
|
dto["mon"],
|
||
|
dto["day"],
|
||
|
dto["hour"],
|
||
|
dto["min"],
|
||
|
dto["sec"],
|
||
|
0,
|
||
|
end_date.tzinfo,
|
||
|
)
|
||
|
dto = file["StartTime"]
|
||
|
start_date = dt.datetime(
|
||
|
dto["year"],
|
||
|
dto["mon"],
|
||
|
dto["day"],
|
||
|
dto["hour"],
|
||
|
dto["min"],
|
||
|
dto["sec"],
|
||
|
0,
|
||
|
end_date.tzinfo,
|
||
|
)
|
||
|
event_id = str(start_date.timestamp())
|
||
|
event = events.setdefault(event_id, {})
|
||
|
event["start"] = start_date
|
||
|
event["end"] = end_date
|
||
|
event["file"] = file["name"]
|
||
|
|
||
|
child = await self._async_browse_media(source, camera_id, event_id)
|
||
|
media.children.append(child)
|
||
|
|
||
|
return media
|
||
|
|
||
|
|
||
|
class ReolinkSourceThumbnailView(HomeAssistantView):
|
||
|
""" Thumbnial view handler """
|
||
|
|
||
|
url = "/api/" + DOMAIN + "/media_proxy/{camera_id}/{event_id}"
|
||
|
name = "api:" + DOMAIN + ":image"
|
||
|
requires_auth = False
|
||
|
cors_allowed = True
|
||
|
|
||
|
def __init__(self, hass: HomeAssistant, source: ReolinkSource):
|
||
|
"""Initialize media view """
|
||
|
|
||
|
self.hass = hass
|
||
|
self.source = source
|
||
|
|
||
|
async def get(
|
||
|
self, request: web.Request, camera_id: str, event_id: str
|
||
|
) -> web.Response:
|
||
|
""" start a GET request. """
|
||
|
|
||
|
if not camera_id or not event_id:
|
||
|
raise web.HTTPNotFound()
|
||
|
|
||
|
cache: typings.MediaSourceCacheEntry = self.source.cache.get(camera_id, None)
|
||
|
if cache is None or "playback_events" not in cache:
|
||
|
_LOGGER.debug("camera %s not found", camera_id)
|
||
|
raise web.HTTPNotFound()
|
||
|
|
||
|
event = cache["playback_events"].get(event_id, None)
|
||
|
if event is None:
|
||
|
_LOGGER.debug("camera %s, event %s not found", camera_id, event_id)
|
||
|
raise web.HTTPNotFound()
|
||
|
|
||
|
token = request.query.get("token")
|
||
|
if (token and event.get("token") != token) or (
|
||
|
not token and not self.requires_auth
|
||
|
):
|
||
|
_LOGGER.debug(
|
||
|
"invalid or missing token %s for camera %s, event %s",
|
||
|
token,
|
||
|
camera_id,
|
||
|
event_id,
|
||
|
)
|
||
|
raise web.HTTPNotFound()
|
||
|
|
||
|
_LOGGER.debug("thumbnail %s, %s", camera_id, event_id)
|
||
|
|
||
|
base: ReolinkBase = self.hass.data[DOMAIN][cache["entry_id"]][BASE]
|
||
|
|
||
|
image = event.get("thumbnail", None)
|
||
|
if (
|
||
|
image is None
|
||
|
or cache.get("playback_thumbnail_offset", DEFAULT_THUMBNAIL_OFFSET)
|
||
|
!= base.playback_thumbnail_offset
|
||
|
):
|
||
|
cache["playback_thumbnails"] = base.playback_thumbnails
|
||
|
cache["playback_thumbnail_offset"] = base.playback_thumbnail_offset
|
||
|
|
||
|
if not cache["playback_thumbnails"]:
|
||
|
_LOGGER.debug("Thumbnails not allowed on camera %s", camera_id)
|
||
|
raise web.HTTPInternalServerError()
|
||
|
|
||
|
_LOGGER.debug("generating thumbnail for %s, %s", camera_id, event_id)
|
||
|
|
||
|
extra_cmd: str = None
|
||
|
if cache["playback_thumbnail_offset"] > 0:
|
||
|
extra_cmd = f"-ss {cache['playback_thumbnail_offset']}"
|
||
|
|
||
|
image = event["thumbail"] = await async_get_image(
|
||
|
self.hass,
|
||
|
await base.api.get_vod_source(event["file"]),
|
||
|
extra_cmd=extra_cmd,
|
||
|
)
|
||
|
_LOGGER.debug("generated thumbnail for %s, %s", camera_id, event_id)
|
||
|
|
||
|
if image:
|
||
|
return web.Response(body=image, content_type=IMAGE_JPEG)
|
||
|
|
||
|
_LOGGER.debug(
|
||
|
"No thumbnail generated for camera %s, event %s", camera_id, event_id
|
||
|
)
|
||
|
raise web.HTTPInternalServerError()
|
||
|
|
||
|
|
||
|
@callback
|
||
|
def async_parse_identifier(
|
||
|
item: MediaSourceItem,
|
||
|
) -> Tuple[str, str, Optional[str]]:
|
||
|
"""Parse identifier."""
|
||
|
if not item.identifier:
|
||
|
return "events", "", None
|
||
|
|
||
|
source, path = item.identifier.lstrip("/").split("/", 1)
|
||
|
|
||
|
if source != "events":
|
||
|
raise Unresolvable("Unknown source directory.")
|
||
|
|
||
|
if "/" in path:
|
||
|
camera_id, event_id = path.split("/", 1)
|
||
|
|
||
|
return source, camera_id, event_id
|
||
|
|
||
|
return source, path, None
|