Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for interfacing with the XBMC/Kodi JSON-RPC API."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Awaitable, Callable, Coroutine
6 from datetime import timedelta
7 from functools import wraps
8 import logging
9 import re
10 from typing import Any, Concatenate
11 
12 from jsonrpc_base.jsonrpc import ProtocolError, TransportError
13 from pykodi import CannotConnectError
14 import voluptuous as vol
15 
16 from homeassistant.components import media_source
18  PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
19  BrowseError,
20  BrowseMedia,
21  MediaPlayerEntity,
22  MediaPlayerEntityFeature,
23  MediaPlayerState,
24  MediaType,
25  async_process_play_media_url,
26 )
27 from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
28 from homeassistant.const import (
29  ATTR_ENTITY_ID,
30  CONF_DEVICE_ID,
31  CONF_HOST,
32  CONF_NAME,
33  CONF_PASSWORD,
34  CONF_PORT,
35  CONF_PROXY_SSL,
36  CONF_SSL,
37  CONF_TIMEOUT,
38  CONF_TYPE,
39  CONF_USERNAME,
40  EVENT_HOMEASSISTANT_STARTED,
41 )
42 from homeassistant.core import CoreState, HomeAssistant, callback
43 from homeassistant.helpers import (
44  config_validation as cv,
45  device_registry as dr,
46  entity_platform,
47 )
48 from homeassistant.helpers.device_registry import DeviceInfo
49 from homeassistant.helpers.entity_platform import AddEntitiesCallback
50 from homeassistant.helpers.event import async_track_time_interval
51 from homeassistant.helpers.network import is_internal_request
52 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
53 import homeassistant.util.dt as dt_util
54 
55 from .browse_media import (
56  build_item_response,
57  get_media_info,
58  library_payload,
59  media_source_content_filter,
60 )
61 from .const import (
62  CONF_WS_PORT,
63  DATA_CONNECTION,
64  DATA_KODI,
65  DEFAULT_PORT,
66  DEFAULT_SSL,
67  DEFAULT_TIMEOUT,
68  DEFAULT_WS_PORT,
69  DOMAIN,
70  EVENT_TURN_OFF,
71  EVENT_TURN_ON,
72 )
73 
74 _LOGGER = logging.getLogger(__name__)
75 
76 EVENT_KODI_CALL_METHOD_RESULT = "kodi_call_method_result"
77 
78 CONF_TCP_PORT = "tcp_port"
79 CONF_TURN_ON_ACTION = "turn_on_action"
80 CONF_TURN_OFF_ACTION = "turn_off_action"
81 CONF_ENABLE_WEBSOCKET = "enable_websocket"
82 
83 DEPRECATED_TURN_OFF_ACTIONS = {
84  None: None,
85  "quit": "Application.Quit",
86  "hibernate": "System.Hibernate",
87  "suspend": "System.Suspend",
88  "reboot": "System.Reboot",
89  "shutdown": "System.Shutdown",
90 }
91 
92 WEBSOCKET_WATCHDOG_INTERVAL = timedelta(seconds=10)
93 
94 # https://github.com/xbmc/xbmc/blob/master/xbmc/media/MediaType.h
95 MEDIA_TYPES = {
96  "music": MediaType.MUSIC,
97  "artist": MediaType.MUSIC,
98  "album": MediaType.MUSIC,
99  "song": MediaType.MUSIC,
100  "video": MediaType.VIDEO,
101  "set": MediaType.PLAYLIST,
102  "musicvideo": MediaType.VIDEO,
103  "movie": MediaType.MOVIE,
104  "tvshow": MediaType.TVSHOW,
105  "season": MediaType.TVSHOW,
106  "episode": MediaType.TVSHOW,
107  # Type 'channel' is used for radio or tv streams from pvr
108  "channel": MediaType.CHANNEL,
109  # Type 'audio' is used for audio media, that Kodi couldn't scroblle
110  "audio": MediaType.MUSIC,
111 }
112 
113 MAP_KODI_MEDIA_TYPES: dict[MediaType | str, str] = {
114  MediaType.MOVIE: "movieid",
115  MediaType.EPISODE: "episodeid",
116  MediaType.SEASON: "seasonid",
117  MediaType.TVSHOW: "tvshowid",
118 }
119 
120 
121 PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
122  {
123  vol.Required(CONF_HOST): cv.string,
124  vol.Optional(CONF_NAME): cv.string,
125  vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
126  vol.Optional(CONF_TCP_PORT, default=DEFAULT_WS_PORT): cv.port,
127  vol.Optional(CONF_PROXY_SSL, default=DEFAULT_SSL): cv.boolean,
128  vol.Optional(CONF_TURN_ON_ACTION): cv.SCRIPT_SCHEMA,
129  vol.Optional(CONF_TURN_OFF_ACTION): vol.Any(
130  cv.SCRIPT_SCHEMA, vol.In(DEPRECATED_TURN_OFF_ACTIONS)
131  ),
132  vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
133  vol.Inclusive(CONF_USERNAME, "auth"): cv.string,
134  vol.Inclusive(CONF_PASSWORD, "auth"): cv.string,
135  vol.Optional(CONF_ENABLE_WEBSOCKET, default=True): cv.boolean,
136  }
137 )
138 
139 
140 SERVICE_ADD_MEDIA = "add_to_playlist"
141 SERVICE_CALL_METHOD = "call_method"
142 
143 ATTR_MEDIA_TYPE = "media_type"
144 ATTR_MEDIA_NAME = "media_name"
145 ATTR_MEDIA_ARTIST_NAME = "artist_name"
146 ATTR_MEDIA_ID = "media_id"
147 ATTR_METHOD = "method"
148 
149 
150 KODI_ADD_MEDIA_SCHEMA: VolDictType = {
151  vol.Required(ATTR_MEDIA_TYPE): cv.string,
152  vol.Optional(ATTR_MEDIA_ID): cv.string,
153  vol.Optional(ATTR_MEDIA_NAME): cv.string,
154  vol.Optional(ATTR_MEDIA_ARTIST_NAME): cv.string,
155 }
156 
157 KODI_CALL_METHOD_SCHEMA = cv.make_entity_service_schema(
158  {vol.Required(ATTR_METHOD): cv.string}, extra=vol.ALLOW_EXTRA
159 )
160 
161 
162 def find_matching_config_entries_for_host(hass, host):
163  """Search existing config entries for one matching the host."""
164  for entry in hass.config_entries.async_entries(DOMAIN):
165  if entry.data[CONF_HOST] == host:
166  return entry
167  return None
168 
169 
170 async def async_setup_platform(
171  hass: HomeAssistant,
172  config: ConfigType,
173  async_add_entities: AddEntitiesCallback,
174  discovery_info: DiscoveryInfoType | None = None,
175 ) -> None:
176  """Set up the Kodi platform."""
177  if discovery_info:
178  # Now handled by zeroconf in the config flow
179  return
180 
181  host = config[CONF_HOST]
182  if find_matching_config_entries_for_host(hass, host):
183  return
184 
185  websocket = config.get(CONF_ENABLE_WEBSOCKET)
186  ws_port = config.get(CONF_TCP_PORT) if websocket else None
187 
188  entry_data = {
189  CONF_NAME: config.get(CONF_NAME, host),
190  CONF_HOST: host,
191  CONF_PORT: config.get(CONF_PORT),
192  CONF_WS_PORT: ws_port,
193  CONF_USERNAME: config.get(CONF_USERNAME),
194  CONF_PASSWORD: config.get(CONF_PASSWORD),
195  CONF_SSL: config.get(CONF_PROXY_SSL),
196  CONF_TIMEOUT: config.get(CONF_TIMEOUT),
197  }
198 
199  hass.async_create_task(
200  hass.config_entries.flow.async_init(
201  DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_data
202  )
203  )
204 
205 
206 async def async_setup_entry(
207  hass: HomeAssistant,
208  config_entry: ConfigEntry,
209  async_add_entities: AddEntitiesCallback,
210 ) -> None:
211  """Set up the Kodi media player platform."""
212  platform = entity_platform.async_get_current_platform()
213  platform.async_register_entity_service(
214  SERVICE_ADD_MEDIA, KODI_ADD_MEDIA_SCHEMA, "async_add_media_to_playlist"
215  )
216  platform.async_register_entity_service(
217  SERVICE_CALL_METHOD, KODI_CALL_METHOD_SCHEMA, "async_call_method"
218  )
219 
220  data = hass.data[DOMAIN][config_entry.entry_id]
221  connection = data[DATA_CONNECTION]
222  kodi = data[DATA_KODI]
223  name = config_entry.data[CONF_NAME]
224  if (uid := config_entry.unique_id) is None:
225  uid = config_entry.entry_id
226 
227  entity = KodiEntity(connection, kodi, name, uid)
228  async_add_entities([entity])
229 
230 
231 def cmd[_KodiEntityT: KodiEntity, **_P](
232  func: Callable[Concatenate[_KodiEntityT, _P], Awaitable[Any]],
233 ) -> Callable[Concatenate[_KodiEntityT, _P], Coroutine[Any, Any, None]]:
234  """Catch command exceptions."""
235 
236  @wraps(func)
237  async def wrapper(obj: _KodiEntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
238  """Wrap all command methods."""
239  try:
240  await func(obj, *args, **kwargs)
241  except (TransportError, ProtocolError) as exc:
242  # If Kodi is off, we expect calls to fail.
243  if obj.state == MediaPlayerState.OFF:
244  log_function = _LOGGER.debug
245  else:
246  log_function = _LOGGER.error
247  log_function(
248  "Error calling %s on entity %s: %r",
249  func.__name__,
250  obj.entity_id,
251  exc,
252  )
253 
254  return wrapper
255 
256 
257 class KodiEntity(MediaPlayerEntity):
258  """Representation of a XBMC/Kodi device."""
259 
260  _attr_has_entity_name = True
261  _attr_name = None
262  _attr_translation_key = "media_player"
263  _attr_supported_features = (
264  MediaPlayerEntityFeature.BROWSE_MEDIA
265  | MediaPlayerEntityFeature.NEXT_TRACK
266  | MediaPlayerEntityFeature.PAUSE
267  | MediaPlayerEntityFeature.PLAY
268  | MediaPlayerEntityFeature.PLAY_MEDIA
269  | MediaPlayerEntityFeature.PREVIOUS_TRACK
270  | MediaPlayerEntityFeature.SEEK
271  | MediaPlayerEntityFeature.SHUFFLE_SET
272  | MediaPlayerEntityFeature.STOP
273  | MediaPlayerEntityFeature.TURN_OFF
274  | MediaPlayerEntityFeature.TURN_ON
275  | MediaPlayerEntityFeature.VOLUME_MUTE
276  | MediaPlayerEntityFeature.VOLUME_SET
277  | MediaPlayerEntityFeature.VOLUME_STEP
278  )
279 
280  def __init__(self, connection, kodi, name, uid):
281  """Initialize the Kodi entity."""
282  self._connection = connection
283  self._kodi = kodi
284  self._attr_unique_id = uid
285  self._device_id = None
286  self._players = None
287  self._properties = {}
288  self._item = {}
289  self._app_properties = {}
290  self._media_position_updated_at = None
291  self._media_position = None
292  self._connect_error = False
293 
294  self._attr_device_info = DeviceInfo(
295  identifiers={(DOMAIN, uid)},
296  manufacturer="Kodi",
297  name=name,
298  )
299 
300  def _reset_state(self, players=None):
301  self._players = players
302  self._properties = {}
303  self._item = {}
304  self._app_properties = {}
305  self._media_position_updated_at = None
306  self._media_position = None
307 
308  @property
309  def _kodi_is_off(self):
310  return self._players is None
311 
312  @property
313  def _no_active_players(self):
314  return not self._players
315 
316  @callback
317  def async_on_speed_event(self, sender, data):
318  """Handle player changes between playing and paused."""
319  self._properties["speed"] = data["player"]["speed"]
320 
321  if not hasattr(data["item"], "id"):
322  # If no item id is given, perform a full update
323  force_refresh = True
324  else:
325  # If a new item is playing, force a complete refresh
326  force_refresh = data["item"]["id"] != self._item.get("id")
327 
328  self.async_schedule_update_ha_state(force_refresh)
329 
330  @callback
331  def async_on_stop(self, sender, data):
332  """Handle the stop of the player playback."""
333  # Prevent stop notifications which are sent after quit notification
334  if self._kodi_is_off:
335  return
336 
337  self._reset_state([])
338  self.async_write_ha_state()
339 
340  @callback
341  def async_on_volume_changed(self, sender, data):
342  """Handle the volume changes."""
343  self._app_properties["volume"] = data["volume"]
344  self._app_properties["muted"] = data["muted"]
345  self.async_write_ha_state()
346 
347  @callback
348  def async_on_key_press(self, sender, data):
349  """Handle a incoming key press notification."""
350  self.hass.bus.async_fire(
351  f"{DOMAIN}_keypress",
352  {
353  CONF_TYPE: "keypress",
354  CONF_DEVICE_ID: self._device_id,
355  ATTR_ENTITY_ID: self.entity_id,
356  "sender": sender,
357  "data": data,
358  },
359  )
360 
361  async def async_on_quit(self, sender, data):
362  """Reset the player state on quit action."""
363  await self._clear_connection()
364 
365  async def _clear_connection(self, close=True):
366  self._reset_state()
367  self.async_write_ha_state()
368  if close:
369  await self._connection.close()
370 
371  @property
372  def state(self) -> MediaPlayerState:
373  """Return the state of the device."""
374  if self._kodi_is_off:
375  return MediaPlayerState.OFF
376 
377  if self._no_active_players:
378  return MediaPlayerState.IDLE
379 
380  if self._properties["speed"] == 0:
381  return MediaPlayerState.PAUSED
382 
383  return MediaPlayerState.PLAYING
384 
385  async def async_added_to_hass(self) -> None:
386  """Connect the websocket if needed."""
387  if not self._connection.can_subscribe:
388  return
389 
390  if self._connection.connected:
391  await self._on_ws_connected()
392 
393  async def start_watchdog(event=None):
394  """Start websocket watchdog."""
395  await self._async_connect_websocket_if_disconnected()
396  self.async_on_remove(
398  self.hass,
399  self._async_connect_websocket_if_disconnected,
400  WEBSOCKET_WATCHDOG_INTERVAL,
401  )
402  )
403 
404  # If Home Assistant is already in a running state, start the watchdog
405  # immediately, else trigger it after Home Assistant has finished starting.
406  if self.hass.state is CoreState.running:
407  await start_watchdog()
408  else:
409  self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_watchdog)
410 
411  async def _on_ws_connected(self):
412  """Call after ws is connected."""
413  self._connect_error = False
414  self._register_ws_callbacks()
415 
416  version = (await self._kodi.get_application_properties(["version"]))["version"]
417  sw_version = f"{version['major']}.{version['minor']}"
418  dev_reg = dr.async_get(self.hass)
419  device = dev_reg.async_get_device(identifiers={(DOMAIN, self.unique_id)})
420  dev_reg.async_update_device(device.id, sw_version=sw_version)
421  self._device_id = device.id
422 
423  self.async_schedule_update_ha_state(True)
424 
425  async def _async_ws_connect(self):
426  """Connect to Kodi via websocket protocol."""
427  try:
428  await self._connection.connect()
429  await self._on_ws_connected()
430  except (TransportError, CannotConnectError):
431  if not self._connect_error:
432  self._connect_error = True
433  _LOGGER.warning("Unable to connect to Kodi via websocket")
434  await self._clear_connection(False)
435  else:
436  self._connect_error = False
437 
438  async def _ping(self):
439  try:
440  await self._kodi.ping()
441  except (TransportError, CannotConnectError):
442  if not self._connect_error:
443  self._connect_error = True
444  _LOGGER.warning("Unable to ping Kodi via websocket")
445  await self._clear_connection()
446  else:
447  self._connect_error = False
448 
449  async def _async_connect_websocket_if_disconnected(self, *_):
450  """Reconnect the websocket if it fails."""
451  if not self._connection.connected:
452  await self._async_ws_connect()
453  else:
454  await self._ping()
455 
456  @callback
457  def _register_ws_callbacks(self):
458  self._connection.server.Player.OnPause = self.async_on_speed_event
459  self._connection.server.Player.OnPlay = self.async_on_speed_event
460  self._connection.server.Player.OnAVStart = self.async_on_speed_event
461  self._connection.server.Player.OnAVChange = self.async_on_speed_event
462  self._connection.server.Player.OnResume = self.async_on_speed_event
463  self._connection.server.Player.OnSpeedChanged = self.async_on_speed_event
464  self._connection.server.Player.OnSeek = self.async_on_speed_event
465  self._connection.server.Player.OnStop = self.async_on_stop
466  self._connection.server.Application.OnVolumeChanged = (
467  self.async_on_volume_changed
468  )
469  self._connection.server.Other.OnKeyPress = self.async_on_key_press
470  self._connection.server.System.OnQuit = self.async_on_quit
471  self._connection.server.System.OnRestart = self.async_on_quit
472  self._connection.server.System.OnSleep = self.async_on_quit
473 
474  @cmd
475  async def async_update(self) -> None:
476  """Retrieve latest state."""
477  if not self._connection.connected:
478  self._reset_state()
479  return
480 
481  try:
482  self._players = await self._kodi.get_players()
483  except (TransportError, ProtocolError):
484  if not self._connection.can_subscribe:
485  self._reset_state()
486  return
487  raise
488 
489  if self._kodi_is_off:
490  self._reset_state()
491  return
492 
493  if self._players:
494  self._app_properties = await self._kodi.get_application_properties(
495  ["volume", "muted"]
496  )
497 
498  self._properties = await self._kodi.get_player_properties(
499  self._players[0], ["time", "totaltime", "speed", "live"]
500  )
501 
502  position = self._properties["time"]
503  if self._media_position != position:
504  self._media_position_updated_at = dt_util.utcnow()
505  self._media_position = position
506 
507  self._item = await self._kodi.get_playing_item_properties(
508  self._players[0],
509  [
510  "title",
511  "file",
512  "uniqueid",
513  "thumbnail",
514  "artist",
515  "albumartist",
516  "showtitle",
517  "album",
518  "season",
519  "episode",
520  "streamdetails",
521  ],
522  )
523  else:
524  self._reset_state([])
525 
526  @property
527  def should_poll(self) -> bool:
528  """Return True if entity has to be polled for state."""
529  return not self._connection.can_subscribe
530 
531  @property
532  def volume_level(self) -> float | None:
533  """Volume level of the media player (0..1)."""
534  if "volume" in self._app_properties:
535  return int(self._app_properties["volume"]) / 100.0
536  return None
537 
538  @property
539  def is_volume_muted(self):
540  """Boolean if volume is currently muted."""
541  return self._app_properties.get("muted")
542 
543  @property
544  def media_content_id(self):
545  """Content ID of current playing media."""
546  return self._item.get("uniqueid", None)
547 
548  @property
549  def media_content_type(self):
550  """Content type of current playing media.
551 
552  If the media type cannot be detected, the player type is used.
553  """
554  item_type = MEDIA_TYPES.get(self._item.get("type"))
555  if (item_type is None or item_type == "channel") and self._players:
556  return MEDIA_TYPES.get(self._players[0]["type"])
557  return item_type
558 
559  @property
560  def media_duration(self):
561  """Duration of current playing media in seconds."""
562  if self._properties.get("live"):
563  return None
564 
565  if (total_time := self._properties.get("totaltime")) is None:
566  return None
567 
568  return (
569  total_time["hours"] * 3600
570  + total_time["minutes"] * 60
571  + total_time["seconds"]
572  )
573 
574  @property
575  def media_position(self):
576  """Position of current playing media in seconds."""
577  if (time := self._properties.get("time")) is None:
578  return None
579 
580  return time["hours"] * 3600 + time["minutes"] * 60 + time["seconds"]
581 
582  @property
583  def media_position_updated_at(self):
584  """Last valid time of media position."""
585  return self._media_position_updated_at
586 
587  @property
588  def media_image_url(self):
589  """Image url of current playing media."""
590  if (thumbnail := self._item.get("thumbnail")) is None:
591  return None
592 
593  return self._kodi.thumbnail_url(thumbnail)
594 
595  @property
596  def media_title(self):
597  """Title of current playing media."""
598  # find a string we can use as a title
599  item = self._item
600  return item.get("title") or item.get("label") or item.get("file")
601 
602  @property
603  def media_series_title(self):
604  """Title of series of current playing media, TV show only."""
605  return self._item.get("showtitle")
606 
607  @property
608  def media_season(self):
609  """Season of current playing media, TV show only."""
610  return self._item.get("season")
611 
612  @property
613  def media_episode(self):
614  """Episode of current playing media, TV show only."""
615  return self._item.get("episode")
616 
617  @property
618  def media_album_name(self):
619  """Album name of current playing media, music track only."""
620  return self._item.get("album")
621 
622  @property
623  def media_artist(self):
624  """Artist of current playing media, music track only."""
625  if artists := self._item.get("artist"):
626  return artists[0]
627 
628  return None
629 
630  @property
631  def media_album_artist(self):
632  """Album artist of current playing media, music track only."""
633  if artists := self._item.get("albumartist"):
634  return artists[0]
635 
636  return None
637 
638  @property
639  def extra_state_attributes(self) -> dict[str, str | None]:
640  """Return the state attributes."""
641  state_attr: dict[str, str | None] = {}
642  if self.state == MediaPlayerState.OFF:
643  return state_attr
644 
645  state_attr["dynamic_range"] = "sdr"
646  if (video_details := self._item.get("streamdetails", {}).get("video")) and (
647  hdr_type := video_details[0].get("hdrtype")
648  ):
649  state_attr["dynamic_range"] = hdr_type
650 
651  return state_attr
652 
653  async def async_turn_on(self) -> None:
654  """Turn the media player on."""
655  _LOGGER.debug("Firing event to turn on device")
656  self.hass.bus.async_fire(EVENT_TURN_ON, {ATTR_ENTITY_ID: self.entity_id})
657 
658  async def async_turn_off(self) -> None:
659  """Turn the media player off."""
660  _LOGGER.debug("Firing event to turn off device")
661  self.hass.bus.async_fire(EVENT_TURN_OFF, {ATTR_ENTITY_ID: self.entity_id})
662 
663  @cmd
664  async def async_volume_up(self) -> None:
665  """Volume up the media player."""
666  await self._kodi.volume_up()
667 
668  @cmd
669  async def async_volume_down(self) -> None:
670  """Volume down the media player."""
671  await self._kodi.volume_down()
672 
673  @cmd
674  async def async_set_volume_level(self, volume: float) -> None:
675  """Set volume level, range 0..1."""
676  await self._kodi.set_volume_level(int(volume * 100))
677 
678  @cmd
679  async def async_mute_volume(self, mute: bool) -> None:
680  """Mute (true) or unmute (false) media player."""
681  await self._kodi.mute(mute)
682 
683  @cmd
684  async def async_media_play_pause(self) -> None:
685  """Pause media on media player."""
686  await self._kodi.play_pause()
687 
688  @cmd
689  async def async_media_play(self) -> None:
690  """Play media."""
691  await self._kodi.play()
692 
693  @cmd
694  async def async_media_pause(self) -> None:
695  """Pause the media player."""
696  await self._kodi.pause()
697 
698  @cmd
699  async def async_media_stop(self) -> None:
700  """Stop the media player."""
701  await self._kodi.stop()
702 
703  @cmd
704  async def async_media_next_track(self) -> None:
705  """Send next track command."""
706  await self._kodi.next_track()
707 
708  @cmd
709  async def async_media_previous_track(self) -> None:
710  """Send next track command."""
711  await self._kodi.previous_track()
712 
713  @cmd
714  async def async_media_seek(self, position: float) -> None:
715  """Send seek command."""
716  await self._kodi.media_seek(position)
717 
718  @cmd
719  async def async_play_media(
720  self, media_type: MediaType | str, media_id: str, **kwargs: Any
721  ) -> None:
722  """Send the play_media command to the media player."""
723  if media_source.is_media_source_id(media_id):
724  media_type = MediaType.URL
725  play_item = await media_source.async_resolve_media(
726  self.hass, media_id, self.entity_id
727  )
728  media_id = play_item.url
729 
730  media_type_lower = media_type.lower()
731 
732  if media_type_lower == MediaType.CHANNEL:
733  await self._kodi.play_channel(int(media_id))
734  elif media_type_lower == MediaType.PLAYLIST:
735  await self._kodi.play_playlist(int(media_id))
736  elif media_type_lower == "file":
737  await self._kodi.play_file(media_id)
738  elif media_type_lower == "directory":
739  await self._kodi.play_directory(media_id)
740  elif media_type_lower in [
741  MediaType.ARTIST,
742  MediaType.ALBUM,
743  MediaType.TRACK,
744  ]:
745  await self.async_clear_playlist()
746  await self.async_add_to_playlist(media_type_lower, media_id)
747  await self._kodi.play_playlist(0)
748  elif media_type_lower in [
749  MediaType.MOVIE,
750  MediaType.EPISODE,
751  MediaType.SEASON,
752  MediaType.TVSHOW,
753  ]:
754  await self._kodi.play_item(
755  {MAP_KODI_MEDIA_TYPES[media_type_lower]: int(media_id)}
756  )
757  else:
758  media_id = async_process_play_media_url(self.hass, media_id)
759 
760  await self._kodi.play_file(media_id)
761 
762  @cmd
763  async def async_set_shuffle(self, shuffle: bool) -> None:
764  """Set shuffle mode, for the first player."""
765  if self._no_active_players:
766  raise RuntimeError("Error: No active player.")
767  await self._kodi.set_shuffle(shuffle)
768 
769  async def async_call_method(self, method, **kwargs):
770  """Run Kodi JSONRPC API method with params."""
771  _LOGGER.debug("Run API method %s, kwargs=%s", method, kwargs)
772  result_ok = False
773  try:
774  result = await self._kodi.call_method(method, **kwargs)
775  result_ok = True
776  except ProtocolError as exc:
777  result = exc.args[2]["error"]
778  _LOGGER.error(
779  "Run API method %s.%s(%s) error: %s",
780  self.entity_id,
781  method,
782  kwargs,
783  result,
784  )
785  except TransportError:
786  result = None
787  _LOGGER.warning(
788  "TransportError trying to run API method %s.%s(%s)",
789  self.entity_id,
790  method,
791  kwargs,
792  )
793 
794  if isinstance(result, dict):
795  event_data = {
796  "entity_id": self.entity_id,
797  "result": result,
798  "result_ok": result_ok,
799  "input": {"method": method, "params": kwargs},
800  }
801  _LOGGER.debug("EVENT kodi_call_method_result: %s", event_data)
802  self.hass.bus.async_fire(
803  EVENT_KODI_CALL_METHOD_RESULT, event_data=event_data
804  )
805  return result
806 
807  async def async_clear_playlist(self) -> None:
808  """Clear default playlist (i.e. playlistid=0)."""
809  await self._kodi.clear_playlist()
810 
811  async def async_add_to_playlist(self, media_type, media_id):
812  """Add media item to default playlist (i.e. playlistid=0)."""
813  if media_type == MediaType.ARTIST:
814  await self._kodi.add_artist_to_playlist(int(media_id))
815  elif media_type == MediaType.ALBUM:
816  await self._kodi.add_album_to_playlist(int(media_id))
817  elif media_type == MediaType.TRACK:
818  await self._kodi.add_song_to_playlist(int(media_id))
819 
820  async def async_add_media_to_playlist(
821  self, media_type, media_id=None, media_name="ALL", artist_name=""
822  ):
823  """Add a media to default playlist.
824 
825  First the media type must be selected, then
826  the media can be specified in terms of id or
827  name and optionally artist name.
828  All the albums of an artist can be added with
829  media_name="ALL"
830  """
831  if media_type == "SONG":
832  if media_id is None:
833  media_id = await self._async_find_song(media_name, artist_name)
834  if media_id:
835  await self._kodi.add_song_to_playlist(int(media_id))
836 
837  elif media_type == "ALBUM":
838  if media_id is None:
839  if media_name == "ALL":
840  await self._async_add_all_albums(artist_name)
841  return
842 
843  media_id = await self._async_find_album(media_name, artist_name)
844  if media_id:
845  await self._kodi.add_album_to_playlist(int(media_id))
846 
847  else:
848  raise RuntimeError("Unrecognized media type.")
849 
850  if media_id is None:
851  _LOGGER.warning("No media detected for Playlist.Add")
852 
853  async def _async_add_all_albums(self, artist_name):
854  """Add all albums of an artist to default playlist (i.e. playlistid=0).
855 
856  The artist is specified in terms of name.
857  """
858  artist_id = await self._async_find_artist(artist_name)
859 
860  albums = await self._kodi.get_albums(artist_id)
861 
862  for alb in albums["albums"]:
863  await self._kodi.add_album_to_playlist(int(alb["albumid"]))
864 
865  async def _async_find_artist(self, artist_name):
866  """Find artist by name."""
867  artists = await self._kodi.get_artists()
868  try:
869  out = self._find(artist_name, [a["artist"] for a in artists["artists"]])
870  return artists["artists"][out[0][0]]["artistid"]
871  except KeyError:
872  _LOGGER.warning("No artists were found: %s", artist_name)
873  return None
874 
875  async def _async_find_song(self, song_name, artist_name=""):
876  """Find song by name and optionally artist name."""
877  artist_id = None
878  if artist_name != "":
879  artist_id = await self._async_find_artist(artist_name)
880 
881  songs = await self._kodi.get_songs(artist_id)
882  if songs["limits"]["total"] == 0:
883  return None
884 
885  out = self._find(song_name, [a["label"] for a in songs["songs"]])
886  return songs["songs"][out[0][0]]["songid"]
887 
888  async def _async_find_album(self, album_name, artist_name=""):
889  """Find album by name and optionally artist name."""
890  artist_id = None
891  if artist_name != "":
892  artist_id = await self._async_find_artist(artist_name)
893 
894  albums = await self._kodi.get_albums(artist_id)
895  try:
896  out = self._find(album_name, [a["label"] for a in albums["albums"]])
897  return albums["albums"][out[0][0]]["albumid"]
898  except KeyError:
899  _LOGGER.warning(
900  "No albums were found with artist: %s, album: %s",
901  artist_name,
902  album_name,
903  )
904  return None
905 
906  @staticmethod
907  def _find(key_word, words):
908  key_word = key_word.split(" ")
909  patt = [re.compile(f"(^| ){k}( |$)", re.IGNORECASE) for k in key_word]
910 
911  out = [[i, 0] for i in range(len(words))]
912  for i in range(len(words)):
913  mtc = [p.search(words[i]) for p in patt]
914  rate = [m is not None for m in mtc].count(True)
915  out[i][1] = rate
916 
917  return sorted(out, key=lambda out: out[1], reverse=True)
918 
919  async def async_browse_media(
920  self,
921  media_content_type: MediaType | str | None = None,
922  media_content_id: str | None = None,
923  ) -> BrowseMedia:
924  """Implement the websocket media browsing helper."""
925  is_internal = is_internal_request(self.hass)
926 
927  async def _get_thumbnail_url(
928  media_content_type,
929  media_content_id,
930  media_image_id=None,
931  thumbnail_url=None,
932  ):
933  if is_internal:
934  return self._kodi.thumbnail_url(thumbnail_url)
935 
936  return self.get_browse_image_url(
937  media_content_type,
938  media_content_id,
939  media_image_id,
940  )
941 
942  if media_content_type in [None, "library"]:
943  return await library_payload(self.hass)
944 
945  if media_content_id and media_source.is_media_source_id(media_content_id):
946  return await media_source.async_browse_media(
947  self.hass, media_content_id, content_filter=media_source_content_filter
948  )
949 
950  payload = {
951  "search_type": media_content_type,
952  "search_id": media_content_id,
953  }
954 
955  response = await build_item_response(self._kodi, payload, _get_thumbnail_url)
956  if response is None:
957  raise BrowseError(
958  f"Media not found: {media_content_type} / {media_content_id}"
959  )
960  return response
961 
962  async def async_get_browse_image(
963  self,
964  media_content_type: MediaType | str,
965  media_content_id: str,
966  media_image_id: str | None = None,
967  ) -> tuple[bytes | None, str | None]:
968  """Get media image from kodi server."""
969  try:
970  image_url, _, _ = await get_media_info(
971  self._kodi, media_content_id, media_content_type
972  )
973  except (ProtocolError, TransportError):
974  return (None, None)
975 
976  if image_url:
977  return await self._async_fetch_image(image_url)
978 
979  return (None, None)
None __init__(self, _AOSmithCoordinatorT coordinator, str junction_id)
Definition: entity.py:20
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_turn_on(self, **Any kwargs)
Definition: light.py:280
None async_turn_off(self, **Any kwargs)
Definition: light.py:314
dict[str, bool] extra_state_attributes(self)
Definition: light.py:332
tuple[str|None, list[dict[str, Any]]|None, str|None] get_media_info(HomeAssistant hass, JellyfinClient client, str user_id, str|None media_content_type, str media_content_id)
BrowseMedia build_item_response(HomeAssistant hass, JellyfinClient client, str user_id, str|None media_content_type, str media_content_id)
Definition: browse_media.py:97
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:32
BrowseMedia|None async_browse_media(HomeAssistant hass, MediaType|str media_content_type, str media_content_id, str cast_type)
Definition: cast.py:56
bool async_play_media(HomeAssistant hass, str cast_entity_id, Chromecast chromecast, MediaType|str media_type, str media_id)
Definition: cast.py:123
str async_process_play_media_url(HomeAssistant hass, str media_content_id, *bool allow_relative_url=False, bool for_supervisor_network=False)
Definition: browse_media.py:36
def _find(list[dict[str, Any]] regions, region_id)
Definition: config_flow.py:148
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
bool state(HomeAssistant hass, str|State|None entity, Any req_state, timedelta|None for_period=None, str|None attribute=None, TemplateVarsType variables=None)
Definition: condition.py:551
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679
bool is_internal_request(HomeAssistant hass)
Definition: network.py:31