Home Assistant Unofficial Reference 2024.12.1
media_source.py
Go to the documentation of this file.
1 """motionEye Media Source Implementation."""
2 
3 from __future__ import annotations
4 
5 import logging
6 from pathlib import PurePath
7 from typing import cast
8 
9 from motioneye_client.const import KEY_MEDIA_LIST, KEY_MIME_TYPE, KEY_PATH
10 
11 from homeassistant.components.media_player import MediaClass, MediaType
13  BrowseMediaSource,
14  MediaSource,
15  MediaSourceError,
16  MediaSourceItem,
17  PlayMedia,
18  Unresolvable,
19 )
20 from homeassistant.config_entries import ConfigEntry
21 from homeassistant.core import HomeAssistant, callback
22 from homeassistant.helpers import device_registry as dr
23 
24 from . import get_media_url, split_motioneye_device_identifier
25 from .const import CONF_CLIENT, DOMAIN
26 
27 MIME_TYPE_MAP = {
28  "movies": "video/mp4",
29  "images": "image/jpeg",
30 }
31 
32 MEDIA_CLASS_MAP = {
33  "movies": MediaClass.VIDEO,
34  "images": MediaClass.IMAGE,
35 }
36 
37 _LOGGER = logging.getLogger(__name__)
38 
39 
40 # Hierarchy:
41 #
42 # url (e.g. http://my-motioneye-1, http://my-motioneye-2)
43 # -> Camera (e.g. "Office", "Kitchen")
44 # -> kind (e.g. Images, Movies)
45 # -> path hierarchy as configured on motionEye
46 
47 
48 async def async_get_media_source(hass: HomeAssistant) -> MotionEyeMediaSource:
49  """Set up motionEye media source."""
50  return MotionEyeMediaSource(hass)
51 
52 
54  """Provide motionEye stills and videos as media sources."""
55 
56  name: str = "motionEye Media"
57 
58  def __init__(self, hass: HomeAssistant) -> None:
59  """Initialize MotionEyeMediaSource."""
60  super().__init__(DOMAIN)
61  self.hasshass = hass
62 
63  async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
64  """Resolve media to a url."""
65  config_id, device_id, kind, path = self._parse_identifier_parse_identifier(item.identifier)
66 
67  if not config_id or not device_id or not kind or not path:
68  raise Unresolvable(
69  f"Incomplete media identifier specified: {item.identifier}"
70  )
71 
72  config = self._get_config_or_raise_get_config_or_raise(config_id)
73  device = self._get_device_or_raise_get_device_or_raise(device_id)
74  self._verify_kind_or_raise_verify_kind_or_raise(kind)
75 
76  url = get_media_url(
77  self.hasshass.data[DOMAIN][config.entry_id][CONF_CLIENT],
78  self._get_camera_id_or_raise_get_camera_id_or_raise(config, device),
79  self._get_path_or_raise_get_path_or_raise(path),
80  kind == "images",
81  )
82  if not url:
83  raise Unresolvable(f"Could not resolve media item: {item.identifier}")
84 
85  return PlayMedia(url, MIME_TYPE_MAP[kind])
86 
87  @callback
88  @classmethod
90  cls, identifier: str
91  ) -> tuple[str | None, str | None, str | None, str | None]:
92  base = [None] * 4
93  data = identifier.split("#", 3)
94  return cast(
95  tuple[str | None, str | None, str | None, str | None],
96  tuple(data + base)[:4], # type: ignore[operator]
97  )
98 
99  async def async_browse_media(
100  self,
101  item: MediaSourceItem,
102  ) -> BrowseMediaSource:
103  """Return media."""
104  if item.identifier:
105  config_id, device_id, kind, path = self._parse_identifier_parse_identifier(item.identifier)
106  config = device = None
107  if config_id:
108  config = self._get_config_or_raise_get_config_or_raise(config_id)
109  if device_id:
110  device = self._get_device_or_raise_get_device_or_raise(device_id)
111  if kind:
112  self._verify_kind_or_raise_verify_kind_or_raise(kind)
113  path = self._get_path_or_raise_get_path_or_raise(path)
114 
115  if config and device and kind:
116  return await self._build_media_path_build_media_path(config, device, kind, path)
117  if config and device:
118  return self._build_media_kinds_build_media_kinds(config, device)
119  if config:
120  return self._build_media_devices_build_media_devices(config)
121  return self._build_media_configs_build_media_configs()
122 
123  def _get_config_or_raise(self, config_id: str) -> ConfigEntry:
124  """Get a config entry from a URL."""
125  entry = self.hasshass.config_entries.async_get_entry(config_id)
126  if not entry:
127  raise MediaSourceError(f"Unable to find config entry with id: {config_id}")
128  return entry
129 
130  def _get_device_or_raise(self, device_id: str) -> dr.DeviceEntry:
131  """Get a config entry from a URL."""
132  device_registry = dr.async_get(self.hasshass)
133  if not (device := device_registry.async_get(device_id)):
134  raise MediaSourceError(f"Unable to find device with id: {device_id}")
135  return device
136 
137  @classmethod
138  def _verify_kind_or_raise(cls, kind: str) -> None:
139  """Verify kind is an expected value."""
140  if kind in MEDIA_CLASS_MAP:
141  return
142  raise MediaSourceError(f"Unknown media type: {kind}")
143 
144  @classmethod
145  def _get_path_or_raise(cls, path: str | None) -> str:
146  """Verify path is a valid motionEye path."""
147  if not path:
148  return "/"
149  if PurePath(path).root == "/":
150  return path
151  raise MediaSourceError(
152  f"motionEye media path must start with '/', received: {path}"
153  )
154 
155  @classmethod
157  cls, config: ConfigEntry, device: dr.DeviceEntry
158  ) -> int:
159  """Get a config entry from a URL."""
160  for identifier in device.identifiers:
161  data = split_motioneye_device_identifier(identifier)
162  if data is not None:
163  return data[2]
164  raise MediaSourceError(f"Could not find camera id for device id: {device.id}")
165 
166  @classmethod
167  def _build_media_config(cls, config: ConfigEntry) -> BrowseMediaSource:
168  return BrowseMediaSource(
169  domain=DOMAIN,
170  identifier=config.entry_id,
171  media_class=MediaClass.DIRECTORY,
172  media_content_type="",
173  title=config.title,
174  can_play=False,
175  can_expand=True,
176  children_media_class=MediaClass.DIRECTORY,
177  )
178 
179  def _build_media_configs(self) -> BrowseMediaSource:
180  """Build the media sources for config entries."""
181  return BrowseMediaSource(
182  domain=DOMAIN,
183  identifier="",
184  media_class=MediaClass.DIRECTORY,
185  media_content_type="",
186  title="motionEye Media",
187  can_play=False,
188  can_expand=True,
189  children=[
190  self._build_media_config_build_media_config(entry)
191  for entry in self.hasshass.config_entries.async_entries(DOMAIN)
192  ],
193  children_media_class=MediaClass.DIRECTORY,
194  )
195 
196  @classmethod
198  cls,
199  config: ConfigEntry,
200  device: dr.DeviceEntry,
201  full_title: bool = True,
202  ) -> BrowseMediaSource:
203  return BrowseMediaSource(
204  domain=DOMAIN,
205  identifier=f"{config.entry_id}#{device.id}",
206  media_class=MediaClass.DIRECTORY,
207  media_content_type="",
208  title=f"{config.title} {device.name}" if full_title else device.name,
209  can_play=False,
210  can_expand=True,
211  children_media_class=MediaClass.DIRECTORY,
212  )
213 
214  def _build_media_devices(self, config: ConfigEntry) -> BrowseMediaSource:
215  """Build the media sources for device entries."""
216  device_registry = dr.async_get(self.hasshass)
217  devices = dr.async_entries_for_config_entry(device_registry, config.entry_id)
218 
219  base = self._build_media_config_build_media_config(config)
220  base.children = [
221  self._build_media_device_build_media_device(config, device, full_title=False)
222  for device in devices
223  ]
224  return base
225 
226  @classmethod
228  cls,
229  config: ConfigEntry,
230  device: dr.DeviceEntry,
231  kind: str,
232  full_title: bool = True,
233  ) -> BrowseMediaSource:
234  return BrowseMediaSource(
235  domain=DOMAIN,
236  identifier=f"{config.entry_id}#{device.id}#{kind}",
237  media_class=MediaClass.DIRECTORY,
238  media_content_type=(
239  MediaType.VIDEO if kind == "movies" else MediaType.IMAGE
240  ),
241  title=(
242  f"{config.title} {device.name} {kind.title()}"
243  if full_title
244  else kind.title()
245  ),
246  can_play=False,
247  can_expand=True,
248  children_media_class=(
249  MediaClass.VIDEO if kind == "movies" else MediaClass.IMAGE
250  ),
251  )
252 
254  self, config: ConfigEntry, device: dr.DeviceEntry
255  ) -> BrowseMediaSource:
256  base = self._build_media_device_build_media_device(config, device)
257  base.children = [
258  self._build_media_kind_build_media_kind(config, device, kind, full_title=False)
259  for kind in MEDIA_CLASS_MAP
260  ]
261  return base
262 
263  async def _build_media_path(
264  self,
265  config: ConfigEntry,
266  device: dr.DeviceEntry,
267  kind: str,
268  path: str,
269  ) -> BrowseMediaSource:
270  """Build the media sources for media kinds."""
271  base = self._build_media_kind_build_media_kind(config, device, kind)
272 
273  parsed_path = PurePath(path)
274  if path != "/":
275  base.title += f" {PurePath(*parsed_path.parts[1:])}"
276 
277  base.children = []
278 
279  client = self.hasshass.data[DOMAIN][config.entry_id][CONF_CLIENT]
280  camera_id = self._get_camera_id_or_raise_get_camera_id_or_raise(config, device)
281 
282  if kind == "movies":
283  resp = await client.async_get_movies(camera_id)
284  else:
285  resp = await client.async_get_images(camera_id)
286 
287  sub_dirs: set[str] = set()
288  parts = parsed_path.parts
289  media_list = resp.get(KEY_MEDIA_LIST, [])
290 
291  def get_media_sort_key(media: dict) -> str:
292  """Get media sort key."""
293  return media.get(KEY_PATH, "")
294 
295  for media in sorted(media_list, key=get_media_sort_key):
296  if (
297  KEY_PATH not in media
298  or KEY_MIME_TYPE not in media
299  or media[KEY_MIME_TYPE] not in MIME_TYPE_MAP.values()
300  ):
301  continue
302 
303  # Example path: '/2021-04-21/21-13-10.mp4'
304  parts_media = PurePath(media[KEY_PATH]).parts
305 
306  if parts_media[: len(parts)] == parts and len(parts_media) > len(parts):
307  full_child_path = str(PurePath(*parts_media[: len(parts) + 1]))
308  display_child_path = parts_media[len(parts)]
309 
310  # Child is a media file.
311  if len(parts) + 1 == len(parts_media):
312  if kind == "movies":
313  thumbnail_url = client.get_movie_url(
314  camera_id, full_child_path, preview=True
315  )
316  else:
317  thumbnail_url = client.get_image_url(
318  camera_id, full_child_path, preview=True
319  )
320 
321  base.children.append(
323  domain=DOMAIN,
324  identifier=f"{config.entry_id}#{device.id}#{kind}#{full_child_path}",
325  media_class=MEDIA_CLASS_MAP[kind],
326  media_content_type=media[KEY_MIME_TYPE],
327  title=display_child_path,
328  can_play=(kind == "movies"),
329  can_expand=False,
330  thumbnail=thumbnail_url,
331  )
332  )
333 
334  # Child is a subdirectory.
335  elif len(parts) + 1 < len(parts_media):
336  if full_child_path not in sub_dirs:
337  sub_dirs.add(full_child_path)
338  base.children.append(
340  domain=DOMAIN,
341  identifier=(
342  f"{config.entry_id}#{device.id}"
343  f"#{kind}#{full_child_path}"
344  ),
345  media_class=MediaClass.DIRECTORY,
346  media_content_type=(
347  MediaType.VIDEO
348  if kind == "movies"
349  else MediaType.IMAGE
350  ),
351  title=display_child_path,
352  can_play=False,
353  can_expand=True,
354  children_media_class=MediaClass.DIRECTORY,
355  )
356  )
357  return base
int _get_camera_id_or_raise(cls, ConfigEntry config, dr.DeviceEntry device)
BrowseMediaSource _build_media_devices(self, ConfigEntry config)
tuple[str|None, str|None, str|None, str|None] _parse_identifier(cls, str identifier)
Definition: media_source.py:91
BrowseMediaSource _build_media_config(cls, ConfigEntry config)
BrowseMediaSource async_browse_media(self, MediaSourceItem item)
BrowseMediaSource _build_media_device(cls, ConfigEntry config, dr.DeviceEntry device, bool full_title=True)
BrowseMediaSource _build_media_path(self, ConfigEntry config, dr.DeviceEntry device, str kind, str path)
BrowseMediaSource _build_media_kinds(self, ConfigEntry config, dr.DeviceEntry device)
BrowseMediaSource _build_media_kind(cls, ConfigEntry config, dr.DeviceEntry device, str kind, bool full_title=True)
MotionEyeMediaSource async_get_media_source(HomeAssistant hass)
Definition: media_source.py:48
tuple[str, str, int]|None split_motioneye_device_identifier(tuple[str, str] identifier)
Definition: __init__.py:109
str|None get_media_url(MotionEyeClient client, int camera_id, str path, bool image)
Definition: __init__.py:513