home-automation-home-assistant/custom_components/reolink_dev/media_source.py
2021-08-28 21:21:19 +02:00

454 行
15 KiB
Python

"""Reolink Camera Media Source Implementation."""
import datetime as dt
import logging
import os
import secrets
from typing import Dict, List, Optional, Tuple
from urllib.parse import quote_plus, unquote_plus
from aiohttp import web
from dateutil import relativedelta
from homeassistant.components.http.const import KEY_AUTHENTICATED
# from homeassistant.components.http.auth import async_sign_path
# from homeassistant.components.http import current_request
# from homeassistant.components.http.const import KEY_HASS_REFRESH_TOKEN_ID
from homeassistant.core import HomeAssistant, callback
import homeassistant.util.dt as dt_utils
from homeassistant.components.http import HomeAssistantView
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.helpers.event import async_call_later
from .base import ReolinkBase, searchtime_to_datetime
# from . import typings
from .const import (
BASE,
DOMAIN,
DOMAIN_DATA,
LONG_TOKENS,
MEDIA_SOURCE,
SHORT_TOKENS,
THUMBNAIL_EXTENSION as EXTENSION,
THUMBNAIL_URL,
VOD_URL,
)
_LOGGER = logging.getLogger(__name__)
# MIME_TYPE = "rtmp/mp4"
# MIME_TYPE = "video/mp4"
MIME_TYPE = "application/x-mpegURL"
NAME = "Reolink IP Camera"
STORAGE_VERSION = 1
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 = ReolinkMediaSource(hass)
hass.http.register_view(ReolinkSourceThumbnailView(hass))
hass.http.register_view(ReolinkSourceVODView(hass))
return source
class ReolinkMediaSource(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._last_token: dt.datetime = None
@property
def _short_security_token(self):
def clear_token():
tokens.remove(token)
data: dict = self.hass.data.setdefault(DOMAIN_DATA, {})
data = data.setdefault(MEDIA_SOURCE, {})
tokens: List[str] = data.setdefault(SHORT_TOKENS, [])
if len(tokens) < 1 or (
self._last_token and (self._last_token - dt_utils.now()).seconds >= 1800
):
self._last_token = dt_utils.now()
tokens.append(secrets.token_hex())
async_call_later(self.hass, 3600, clear_token)
token = next(iter(tokens), None)
return token
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)
data: dict = self.hass.data[self.domain]
entry: dict = data.get(camera_id) if camera_id else None
base: ReolinkBase = entry.get(BASE) if entry else None
if not base:
raise BrowseError("Camera does not exist.")
file = unquote_plus(event_id)
if not file:
raise BrowseError("Event does not exist.")
_LOGGER.debug("file = %s", file)
url = await base.api.get_vod_source(file)
_LOGGER.debug("Load VOD %s", url)
stream = create_stream(self.hass, url)
stream.add_provider("hls", timeout=3600)
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)
data: dict = self.hass.data[self.domain]
entry: dict = data.get(camera_id) if camera_id else None
base: ReolinkBase = entry.get(BASE) if entry else None
if camera_id and not base:
raise BrowseError("Camera does not exist.")
if event_id and not "/" in event_id:
raise BrowseError("Event does not exist.")
return await self._async_browse_media(source, camera_id, event_id, base)
async def _async_browse_media(
self,
source: str,
camera_id: str = None,
event_id: str = None,
base: ReolinkBase = None,
) -> BrowseMediaSource:
""" actual browse after input validation """
start_date: dt.datetime = None
def create_item(title: str, path: str, thumbnail: bool = False):
nonlocal self, camera_id, event_id, start_date
if not title or not path:
if event_id and "/" in event_id:
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}"
elif base:
title = base.name
path = f"{source}/{camera_id}"
else:
title = self.name
path = source + "/"
media_class = (
MEDIA_CLASS_DIRECTORY
if not event_id or "/" in event_id
else MEDIA_CLASS_VIDEO
)
media = BrowseMediaSource(
domain=self.domain,
identifier=path,
media_class=media_class,
media_content_type=MEDIA_TYPE_VIDEO,
title=title,
can_play=not bool(media_class == MEDIA_CLASS_DIRECTORY),
can_expand=bool(media_class == MEDIA_CLASS_DIRECTORY),
)
if thumbnail:
url = THUMBNAIL_URL.format(camera_id=camera_id, event_id=event_id)
# cannot do authsign as we are in a websocket and isloated from auth and context
# we will continue to use custom tokens
# request = current_request.get()
# refresh_token_id = request.get(KEY_HASS_REFRESH_TOKEN_ID)
# if not refresh_token_id:
# _LOGGER.debug("no token? %s", list(request.keys()))
# # leave expiration 30 seconds?
# media.thumbnail = async_sign_path(
# self.hass, refresh_token_id, url, dt.timedelta(seconds=30)
# )
media.thumbnail = f"{url}?token={self._short_security_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
return media
def create_root_children():
nonlocal base, camera_id
children = []
data: Dict[str, dict] = self.hass.data[self.domain]
for entry_id in data:
entry = data[entry_id]
if not isinstance(entry, dict) or not BASE in entry:
continue
base = entry[BASE]
if not base.api.hdd_info:
continue
camera_id = entry_id
child = create_item(None, None)
children.append(child)
return children
async def create_day_children():
nonlocal event_id
children = []
end_date = dt_utils.now()
start_date = dt.datetime.combine(
end_date.date().replace(day=1), dt.time.min
)
if base.playback_months > 1:
start_date -= relativedelta.relativedelta(
months=int(base.playback_months)
)
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":
event_id = f"{year}/{month}/{day}"
child = create_item(None, None)
children.append(child)
children.reverse()
return children
async def create_vod_children():
nonlocal base, start_date, event_id
children = []
end_date = dt.datetime.combine(
start_date.date(), dt.time.max, start_date.tzinfo
)
_, files = await base.send_search(start_date, end_date)
for file in files:
end_date = searchtime_to_datetime(file["EndTime"], end_date.tzinfo)
start_date = searchtime_to_datetime(file["StartTime"], end_date.tzinfo)
event_id = str(start_date.timestamp())
evt_id = f"{camera_id}/{quote_plus(file['name'])}"
# self._file_cache[evt_id] = file["name"]
thumbnail = os.path.isfile(
f"{base.thumbnail_path}/{event_id}.{EXTENSION}"
)
time = start_date.time()
duration = end_date - start_date
child = create_item(
f"{time} {duration}", f"{source}/{evt_id}", thumbnail
)
children.append(child)
children.reverse()
return children
if base and event_id and not "/" in event_id:
event = base.in_memory_events[event_id]
start_date = event.start
media = create_item(None, None)
if not media.can_expand:
return media
if not camera_id:
media.children = create_root_children()
return media
if not start_date:
media.children = await create_day_children()
else:
media.children = await create_vod_children()
return media
class ReolinkSourceVODView(HomeAssistantView):
""" VOD security handler """
url = VOD_URL
name = "api:" + DOMAIN + ":video"
cors_allowed = True
requires_auth = False
def __init__(self, hass: HomeAssistant):
"""Initialize media view """
self.hass = hass
async def get(
self, request: web.Request, camera_id: str, event_id: str
) -> web.Response:
""" start a GET request. """
authenticated = request.get(KEY_AUTHENTICATED, False)
if not authenticated:
token: str = request.query.get("token")
if not token:
raise web.HTTPUnauthorized()
data: dict = self.hass.data.get(DOMAIN_DATA)
data = data.get(MEDIA_SOURCE) if data else None
tokens: List[str] = data.get(LONG_TOKENS) if data else None
if not tokens or not token in tokens:
raise web.HTTPUnauthorized()
if not camera_id or not event_id:
raise web.HTTPNotFound()
data: Dict[str, dict] = self.hass.data[DOMAIN]
base: ReolinkBase = (
data[camera_id].get(BASE, None) if camera_id in data else None
)
if not base:
_LOGGER.debug("camera %s not found", camera_id)
raise web.HTTPNotFound()
file = unquote_plus(event_id)
url = await base.api.get_vod_source(file)
return web.HTTPTemporaryRedirect(url)
class ReolinkSourceThumbnailView(HomeAssistantView):
""" Thumbnial view handler """
url = THUMBNAIL_URL
name = "api:" + DOMAIN + ":image"
cors_allowed = True
requires_auth = False
def __init__(self, hass: HomeAssistant):
"""Initialize media view """
self.hass = hass
async def get(
self,
request: web.Request, # pylint: disable=unused-argument
camera_id: str,
event_id: str,
) -> web.Response:
""" start a GET request. """
authenticated = request.get(KEY_AUTHENTICATED, False)
if not authenticated:
token: str = request.query.get("token")
if not token:
raise web.HTTPUnauthorized()
data: dict = self.hass.data.get(DOMAIN_DATA)
data = data.get(MEDIA_SOURCE) if data else None
tokens: List[str] = data.get(SHORT_TOKENS) if data else None
if not tokens or not token in tokens:
raise web.HTTPUnauthorized()
if not camera_id or not event_id:
raise web.HTTPNotFound()
data: Dict[str, dict] = self.hass.data[DOMAIN]
base: ReolinkBase = (
data[camera_id].get(BASE, None) if camera_id in data else None
)
if not base:
_LOGGER.debug("camera %s not found", camera_id)
raise web.HTTPNotFound()
thumbnail = f"{base.thumbnail_path}/{event_id}.{EXTENSION}"
return web.FileResponse(thumbnail)
@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