Home Assistant Unofficial Reference 2024.12.1
media_source.py
Go to the documentation of this file.
1 """Expose Reolink IP camera VODs as media sources."""
2 
3 from __future__ import annotations
4 
5 import datetime as dt
6 import logging
7 
8 from reolink_aio.api import DUAL_LENS_MODELS
9 from reolink_aio.enums import VodRequestType
10 
11 from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings
12 from homeassistant.components.media_player import MediaClass, MediaType
14  BrowseMediaSource,
15  MediaSource,
16  MediaSourceItem,
17  PlayMedia,
18  Unresolvable,
19 )
20 from homeassistant.components.stream import create_stream
21 from homeassistant.config_entries import ConfigEntryState
22 from homeassistant.core import HomeAssistant
23 from homeassistant.helpers import device_registry as dr, entity_registry as er
24 
25 from .const import DOMAIN
26 from .host import ReolinkHost
27 from .util import ReolinkConfigEntry
28 
29 _LOGGER = logging.getLogger(__name__)
30 
31 
32 async def async_get_media_source(hass: HomeAssistant) -> ReolinkVODMediaSource:
33  """Set up camera media source."""
34  return ReolinkVODMediaSource(hass)
35 
36 
37 def res_name(stream: str) -> str:
38  """Return the user friendly name for a stream."""
39  match stream:
40  case "main":
41  return "High res."
42  case "autotrack_sub":
43  return "Autotrack low res."
44  case "autotrack_main":
45  return "Autotrack high res."
46  case _:
47  return "Low res."
48 
49 
50 def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost:
51  """Return the Reolink host from the config entry id."""
52  config_entry: ReolinkConfigEntry | None = hass.config_entries.async_get_entry(
53  config_entry_id
54  )
55  assert config_entry is not None
56  return config_entry.runtime_data.host
57 
58 
60  """Provide Reolink camera VODs as media sources."""
61 
62  name: str = "Reolink"
63 
64  def __init__(self, hass: HomeAssistant) -> None:
65  """Initialize ReolinkVODMediaSource."""
66  super().__init__(DOMAIN)
67  self.hasshass = hass
68 
69  async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
70  """Resolve media to a url."""
71  identifier = ["UNKNOWN"]
72  if item.identifier is not None:
73  identifier = item.identifier.split("|", 5)
74  if identifier[0] != "FILE":
75  raise Unresolvable(f"Unknown media item '{item.identifier}'.")
76 
77  _, config_entry_id, channel_str, stream_res, filename = identifier
78  channel = int(channel_str)
79 
80  host = get_host(self.hasshass, config_entry_id)
81 
82  def get_vod_type() -> VodRequestType:
83  if filename.endswith(".mp4"):
84  return VodRequestType.PLAYBACK
85  if host.api.is_nvr:
86  return VodRequestType.FLV
87  return VodRequestType.RTMP
88 
89  vod_type = get_vod_type()
90 
91  mime_type, url = await host.api.get_vod_source(
92  channel, filename, stream_res, vod_type
93  )
94  if _LOGGER.isEnabledFor(logging.DEBUG):
95  url_log = url
96  if "&user=" in url_log:
97  url_log = f"{url_log.split('&user=')[0]}&user=xxxxx&password=xxxxx"
98  elif "&token=" in url_log:
99  url_log = f"{url_log.split('&token=')[0]}&token=xxxxx"
100  _LOGGER.debug(
101  "Opening VOD stream from %s: %s", host.api.camera_name(channel), url_log
102  )
103 
104  if mime_type == "video/mp4":
105  return PlayMedia(url, mime_type)
106 
107  stream = create_stream(self.hasshass, url, {}, DynamicStreamSettings())
108  stream.add_provider("hls", timeout=3600)
109  stream_url: str = stream.endpoint_url("hls")
110  stream_url = stream_url.replace("master_", "")
111  return PlayMedia(stream_url, mime_type)
112 
114  self,
115  item: MediaSourceItem,
116  ) -> BrowseMediaSource:
117  """Return media."""
118  if not item.identifier:
119  return await self._async_generate_root_async_generate_root()
120 
121  identifier = item.identifier.split("|", 7)
122  item_type = identifier[0]
123 
124  if item_type == "CAM":
125  _, config_entry_id, channel_str = identifier
126  return await self._async_generate_resolution_select_async_generate_resolution_select(
127  config_entry_id, int(channel_str)
128  )
129  if item_type == "RES":
130  _, config_entry_id, channel_str, stream = identifier
131  return await self._async_generate_camera_days_async_generate_camera_days(
132  config_entry_id, int(channel_str), stream
133  )
134  if item_type == "DAY":
135  (
136  _,
137  config_entry_id,
138  channel_str,
139  stream,
140  year_str,
141  month_str,
142  day_str,
143  ) = identifier
144  return await self._async_generate_camera_files_async_generate_camera_files(
145  config_entry_id,
146  int(channel_str),
147  stream,
148  int(year_str),
149  int(month_str),
150  int(day_str),
151  )
152 
153  raise Unresolvable(f"Unknown media item '{item.identifier}' during browsing.")
154 
155  async def _async_generate_root(self) -> BrowseMediaSource:
156  """Return all available reolink cameras as root browsing structure."""
157  children: list[BrowseMediaSource] = []
158 
159  entity_reg = er.async_get(self.hasshass)
160  device_reg = dr.async_get(self.hasshass)
161  for config_entry in self.hasshass.config_entries.async_entries(DOMAIN):
162  if config_entry.state != ConfigEntryState.LOADED:
163  continue
164  channels: list[str] = []
165  host = config_entry.runtime_data.host
166  entities = er.async_entries_for_config_entry(
167  entity_reg, config_entry.entry_id
168  )
169  for entity in entities:
170  if (
171  entity.disabled
172  or entity.device_id is None
173  or entity.domain != CAM_DOMAIN
174  ):
175  continue
176 
177  device = device_reg.async_get(entity.device_id)
178  ch_id = entity.unique_id.split("_")[1]
179  if ch_id in channels or device is None:
180  continue
181  channels.append(ch_id)
182 
183  ch: int | str = ch_id
184  if len(ch_id) > 3:
185  ch = host.api.channel_for_uid(ch_id)
186 
187  if not host.api.supported(int(ch), "replay") or not host.api.hdd_info:
188  # playback stream not supported by this camera or no storage installed
189  continue
190 
191  device_name = device.name
192  if device.name_by_user is not None:
193  device_name = device.name_by_user
194 
195  if host.api.model in DUAL_LENS_MODELS:
196  device_name = f"{device_name} lens {ch}"
197 
198  children.append(
200  domain=DOMAIN,
201  identifier=f"CAM|{config_entry.entry_id}|{ch}",
202  media_class=MediaClass.CHANNEL,
203  media_content_type=MediaType.PLAYLIST,
204  title=device_name,
205  thumbnail=f"/api/camera_proxy/{entity.entity_id}",
206  can_play=False,
207  can_expand=True,
208  )
209  )
210 
211  return BrowseMediaSource(
212  domain=DOMAIN,
213  identifier=None,
214  media_class=MediaClass.APP,
215  media_content_type="",
216  title="Reolink",
217  can_play=False,
218  can_expand=True,
219  children=children,
220  )
221 
223  self, config_entry_id: str, channel: int
224  ) -> BrowseMediaSource:
225  """Allow the user to select the high or low playback resolution, (low loads faster)."""
226  host = get_host(self.hasshass, config_entry_id)
227 
228  main_enc = await host.api.get_encoding(channel, "main")
229  if main_enc == "h265":
230  _LOGGER.debug(
231  "Reolink camera %s uses h265 encoding for main stream,"
232  "playback only possible using sub stream",
233  host.api.camera_name(channel),
234  )
235 
236  children = [
238  domain=DOMAIN,
239  identifier=f"RES|{config_entry_id}|{channel}|sub",
240  media_class=MediaClass.CHANNEL,
241  media_content_type=MediaType.PLAYLIST,
242  title="Low resolution",
243  can_play=False,
244  can_expand=True,
245  ),
246  ]
247  if main_enc != "h265":
248  children.append(
250  domain=DOMAIN,
251  identifier=f"RES|{config_entry_id}|{channel}|main",
252  media_class=MediaClass.CHANNEL,
253  media_content_type=MediaType.PLAYLIST,
254  title="High resolution",
255  can_play=False,
256  can_expand=True,
257  ),
258  )
259 
260  if host.api.supported(channel, "autotrack_stream"):
261  children.append(
263  domain=DOMAIN,
264  identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub",
265  media_class=MediaClass.CHANNEL,
266  media_content_type=MediaType.PLAYLIST,
267  title="Autotrack low resolution",
268  can_play=False,
269  can_expand=True,
270  ),
271  )
272  if main_enc != "h265":
273  children.append(
275  domain=DOMAIN,
276  identifier=f"RES|{config_entry_id}|{channel}|autotrack_main",
277  media_class=MediaClass.CHANNEL,
278  media_content_type=MediaType.PLAYLIST,
279  title="Autotrack high resolution",
280  can_play=False,
281  can_expand=True,
282  ),
283  )
284 
285  if len(children) == 1:
286  return await self._async_generate_camera_days_async_generate_camera_days(
287  config_entry_id, channel, "sub"
288  )
289 
290  title = host.api.camera_name(channel)
291  if host.api.model in DUAL_LENS_MODELS:
292  title = f"{host.api.camera_name(channel)} lens {channel}"
293 
294  return BrowseMediaSource(
295  domain=DOMAIN,
296  identifier=f"RESs|{config_entry_id}|{channel}",
297  media_class=MediaClass.CHANNEL,
298  media_content_type=MediaType.PLAYLIST,
299  title=title,
300  can_play=False,
301  can_expand=True,
302  children=children,
303  )
304 
306  self, config_entry_id: str, channel: int, stream: str
307  ) -> BrowseMediaSource:
308  """Return all days on which recordings are available for a reolink camera."""
309  host = get_host(self.hasshass, config_entry_id)
310 
311  # We want today of the camera, not necessarily today of the server
312  now = host.api.time() or await host.api.async_get_time()
313  start = now - dt.timedelta(days=31)
314  end = now
315 
316  if _LOGGER.isEnabledFor(logging.DEBUG):
317  _LOGGER.debug(
318  "Requesting recording days of %s from %s to %s",
319  host.api.camera_name(channel),
320  start,
321  end,
322  )
323  statuses, _ = await host.api.request_vod_files(
324  channel, start, end, status_only=True, stream=stream
325  )
326  children: list[BrowseMediaSource] = [
328  domain=DOMAIN,
329  identifier=f"DAY|{config_entry_id}|{channel}|{stream}|{status.year}|{status.month}|{day}",
330  media_class=MediaClass.DIRECTORY,
331  media_content_type=MediaType.PLAYLIST,
332  title=f"{status.year}/{status.month}/{day}",
333  can_play=False,
334  can_expand=True,
335  )
336  for status in statuses
337  for day in status.days
338  ]
339 
340  title = f"{host.api.camera_name(channel)} {res_name(stream)}"
341  if host.api.model in DUAL_LENS_MODELS:
342  title = f"{host.api.camera_name(channel)} lens {channel} {res_name(stream)}"
343 
344  return BrowseMediaSource(
345  domain=DOMAIN,
346  identifier=f"DAYS|{config_entry_id}|{channel}|{stream}",
347  media_class=MediaClass.CHANNEL,
348  media_content_type=MediaType.PLAYLIST,
349  title=title,
350  can_play=False,
351  can_expand=True,
352  children=children,
353  )
354 
356  self,
357  config_entry_id: str,
358  channel: int,
359  stream: str,
360  year: int,
361  month: int,
362  day: int,
363  ) -> BrowseMediaSource:
364  """Return all recording files on a specific day of a Reolink camera."""
365  host = get_host(self.hasshass, config_entry_id)
366 
367  start = dt.datetime(year, month, day, hour=0, minute=0, second=0)
368  end = dt.datetime(year, month, day, hour=23, minute=59, second=59)
369 
370  children: list[BrowseMediaSource] = []
371  if _LOGGER.isEnabledFor(logging.DEBUG):
372  _LOGGER.debug(
373  "Requesting VODs of %s on %s/%s/%s",
374  host.api.camera_name(channel),
375  year,
376  month,
377  day,
378  )
379  _, vod_files = await host.api.request_vod_files(
380  channel, start, end, stream=stream
381  )
382  for file in vod_files:
383  file_name = f"{file.start_time.time()} {file.duration}"
384  if file.triggers != file.triggers.NONE:
385  file_name += " " + " ".join(
386  str(trigger.name).title()
387  for trigger in file.triggers
388  if trigger != trigger.NONE
389  )
390 
391  children.append(
393  domain=DOMAIN,
394  identifier=f"FILE|{config_entry_id}|{channel}|{stream}|{file.file_name}",
395  media_class=MediaClass.VIDEO,
396  media_content_type=MediaType.VIDEO,
397  title=file_name,
398  can_play=True,
399  can_expand=False,
400  )
401  )
402 
403  title = (
404  f"{host.api.camera_name(channel)} {res_name(stream)} {year}/{month}/{day}"
405  )
406  if host.api.model in DUAL_LENS_MODELS:
407  title = f"{host.api.camera_name(channel)} lens {channel} {res_name(stream)} {year}/{month}/{day}"
408 
409  return BrowseMediaSource(
410  domain=DOMAIN,
411  identifier=f"FILES|{config_entry_id}|{channel}|{stream}",
412  media_class=MediaClass.CHANNEL,
413  media_content_type=MediaType.PLAYLIST,
414  title=title,
415  can_play=False,
416  can_expand=True,
417  children=children,
418  )
Stream create_stream(HomeAssistant hass, str stream_source, Mapping[str, str|bool|float] options, DynamicStreamSettings dynamic_stream_settings, str|None stream_label=None)
Definition: __init__.py:117