Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support to interface with the Plex API."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from functools import wraps
7 import logging
8 from typing import Any, Concatenate, cast
9 
10 import plexapi.exceptions
11 import requests.exceptions
12 
14  DOMAIN as MP_DOMAIN,
15  BrowseMedia,
16  MediaPlayerEntity,
17  MediaPlayerEntityFeature,
18  MediaPlayerState,
19  MediaType,
20 )
21 from homeassistant.config_entries import ConfigEntry
22 from homeassistant.core import HomeAssistant, callback
23 from homeassistant.exceptions import HomeAssistantError
24 from homeassistant.helpers import entity_registry as er
25 from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
27  async_dispatcher_connect,
28  async_dispatcher_send,
29 )
30 from homeassistant.helpers.entity_platform import AddEntitiesCallback
31 from homeassistant.helpers.network import is_internal_request
32 
33 from .const import (
34  COMMON_PLAYERS,
35  CONF_SERVER_IDENTIFIER,
36  DISPATCHERS,
37  DOMAIN,
38  NAME_FORMAT,
39  PLEX_NEW_MP_SIGNAL,
40  PLEX_UPDATE_MEDIA_PLAYER_SESSION_SIGNAL,
41  PLEX_UPDATE_MEDIA_PLAYER_SIGNAL,
42  PLEX_UPDATE_SENSOR_SIGNAL,
43  TRANSIENT_DEVICE_MODELS,
44 )
45 from .helpers import get_plex_data, get_plex_server
46 from .media_browser import browse_media
47 from .services import process_plex_payload
48 
49 _LOGGER = logging.getLogger(__name__)
50 
51 
52 def needs_session[_PlexMediaPlayerT: PlexMediaPlayer, **_P, _R](
53  func: Callable[Concatenate[_PlexMediaPlayerT, _P], _R],
54 ) -> Callable[Concatenate[_PlexMediaPlayerT, _P], _R | None]:
55  """Ensure session is available for certain attributes."""
56 
57  @wraps(func)
58  def get_session_attribute(
59  self: _PlexMediaPlayerT, *args: _P.args, **kwargs: _P.kwargs
60  ) -> _R | None:
61  if self.session is None:
62  return None
63  return func(self, *args, **kwargs)
64 
65  return get_session_attribute
66 
67 
69  hass: HomeAssistant,
70  config_entry: ConfigEntry,
71  async_add_entities: AddEntitiesCallback,
72 ) -> None:
73  """Set up Plex media_player from a config entry."""
74  server_id = config_entry.data[CONF_SERVER_IDENTIFIER]
75  registry = er.async_get(hass)
76 
77  @callback
78  def async_new_media_players(new_entities):
79  _async_add_entities(hass, registry, async_add_entities, server_id, new_entities)
80 
82  hass, PLEX_NEW_MP_SIGNAL.format(server_id), async_new_media_players
83  )
84  get_plex_data(hass)[DISPATCHERS][server_id].append(unsub)
85  _LOGGER.debug("New entity listener created")
86 
87 
88 @callback
89 def _async_add_entities(hass, registry, async_add_entities, server_id, new_entities):
90  """Set up Plex media_player entities."""
91  _LOGGER.debug("New entities: %s", new_entities)
92  entities = []
93  plexserver = get_plex_server(hass, server_id)
94  for entity_params in new_entities:
95  plex_mp = PlexMediaPlayer(plexserver, **entity_params)
96  entities.append(plex_mp)
97 
98  # Migration to per-server unique_ids
99  old_entity_id = registry.async_get_entity_id(
100  MP_DOMAIN, DOMAIN, plex_mp.machine_identifier
101  )
102  if old_entity_id is not None:
103  new_unique_id = f"{server_id}:{plex_mp.machine_identifier}"
104  _LOGGER.debug(
105  "Migrating unique_id from [%s] to [%s]",
106  plex_mp.machine_identifier,
107  new_unique_id,
108  )
109  registry.async_update_entity(old_entity_id, new_unique_id=new_unique_id)
110 
111  async_add_entities(entities, True)
112 
113 
115  """Representation of a Plex device."""
116 
117  _attr_available = False
118  _attr_should_poll = False
119  _attr_state = MediaPlayerState.IDLE
120 
121  def __init__(self, plex_server, device, player_source, session=None):
122  """Initialize the Plex device."""
123  self.plex_serverplex_server = plex_server
124  self.devicedevice = device
125  self.player_sourceplayer_source = player_source
126 
127  self.device_makedevice_make = None
128  self.device_platformdevice_platform = None
129  self.device_productdevice_product = None
130  self.device_titledevice_title = None
131  self.device_versiondevice_version = None
132  self.machine_identifiermachine_identifier = device.machineIdentifier
133  self.session_devicesession_device = None
134 
135  self._device_protocol_capabilities_device_protocol_capabilities = None
136  self._previous_volume_level_previous_volume_level = 1 # Used in fake muting
137  self._volume_level_volume_level = 1 # since we can't retrieve remotely
138  self._volume_muted_volume_muted = False # since we can't retrieve remotely
139 
140  self._attr_unique_id_attr_unique_id = (
141  f"{self.plex_server.machine_identifier}:{self.machine_identifier}"
142  )
143 
144  # Initializes other attributes
145  self.sessionsessionsessionsession = session
146 
147  async def async_added_to_hass(self) -> None:
148  """Run when about to be added to hass."""
149  _LOGGER.debug("Added %s [%s]", self.entity_identity_id, self.unique_idunique_id)
150  self.async_on_removeasync_on_remove(
152  self.hasshass,
153  PLEX_UPDATE_MEDIA_PLAYER_SIGNAL.format(self.unique_idunique_id),
154  self.async_refresh_media_playerasync_refresh_media_player,
155  )
156  )
157 
158  self.async_on_removeasync_on_remove(
160  self.hasshass,
161  PLEX_UPDATE_MEDIA_PLAYER_SESSION_SIGNAL.format(self.unique_idunique_id),
162  self.async_update_from_websocketasync_update_from_websocket,
163  )
164  )
165 
166  @callback
167  def async_refresh_media_player(self, device, session, source):
168  """Set instance objects and trigger an entity state update."""
169  _LOGGER.debug("Refreshing %s [%s / %s]", self.entity_identity_id, device, session)
170  self.devicedevice = device
171  self.sessionsessionsessionsession = session
172  if source:
173  self.player_sourceplayer_source = source
174  self.async_schedule_update_ha_stateasync_schedule_update_ha_state(True)
175 
177  self.hasshass,
178  PLEX_UPDATE_SENSOR_SIGNAL.format(self.plex_serverplex_server.machine_identifier),
179  )
180 
181  @callback
182  def async_update_from_websocket(self, state):
183  """Update the entity based on new websocket data."""
184  self.update_stateupdate_state(state)
185  self.async_write_ha_stateasync_write_ha_state()
186 
188  self.hasshass,
189  PLEX_UPDATE_SENSOR_SIGNAL.format(self.plex_serverplex_server.machine_identifier),
190  )
191 
192  def update(self):
193  """Refresh key device data."""
194  if not self.sessionsessionsessionsession:
195  self.force_idleforce_idle()
196  if not self.devicedevice:
197  self._attr_available_attr_available_attr_available = False
198  return
199 
200  self._attr_available_attr_available_attr_available = True
201 
202  try:
203  device_url = self.devicedevice.url("/")
204  except plexapi.exceptions.BadRequest:
205  device_url = "127.0.0.1"
206  if "127.0.0.1" in device_url:
207  self.devicedevice.proxyThroughServer()
208  self._device_protocol_capabilities_device_protocol_capabilities = self.devicedevice.protocolCapabilities
209 
210  for device in filter(None, [self.devicedevice, self.session_devicesession_device]):
211  self.device_makedevice_make = self.device_makedevice_make or device.device
212  self.device_platformdevice_platform = self.device_platformdevice_platform or device.platform
213  self.device_productdevice_product = self.device_productdevice_product or device.product
214  self.device_titledevice_title = self.device_titledevice_title or device.title
215  self.device_versiondevice_version = self.device_versiondevice_version or device.version
216 
217  name_parts = [self.device_productdevice_product, self.device_titledevice_title or self.device_platformdevice_platform]
218  if (self.device_productdevice_product in COMMON_PLAYERS) and self.device_makedevice_make:
219  # Add more context in name for likely duplicates
220  name_parts.append(self.device_makedevice_make)
221  if self.usernameusername and self.usernameusername != self.plex_serverplex_server.owner:
222  # Prepend username for shared/managed clients
223  name_parts.insert(0, self.usernameusername)
224  self._attr_name_attr_name = NAME_FORMAT.format(" - ".join(name_parts))
225 
226  def force_idle(self):
227  """Force client to idle."""
228  self._attr_state_attr_state = MediaPlayerState.IDLE
229  if self.player_sourceplayer_source == "session":
230  self.devicedevice = None
231  self.session_devicesession_device = None
232  self._attr_available_attr_available_attr_available = False
233 
234  @property
235  def session(self):
236  """Return the active session for this player."""
237  return self._session_session
238 
239  @session.setter
240  def session(self, session):
241  self._session_session = session
242  if session:
243  self.session_devicesession_device = self.sessionsessionsessionsession.player
244  self.update_stateupdate_state(self.sessionsessionsessionsession.state)
245  else:
246  self._attr_state_attr_state = MediaPlayerState.IDLE
247 
248  @property
249  @needs_session
250  def username(self):
251  """Return the username of the client owner."""
252  return self.sessionsessionsessionsession.username
253 
254  def update_state(self, state):
255  """Set the state of the device, handle session termination."""
256  if state == "playing":
257  self._attr_state_attr_state = MediaPlayerState.PLAYING
258  elif state == "paused":
259  self._attr_state_attr_state = MediaPlayerState.PAUSED
260  elif state == "stopped":
261  self.sessionsessionsessionsession = None
262  self.force_idleforce_idle()
263  else:
264  self._attr_state_attr_state = MediaPlayerState.IDLE
265 
266  @property
267  def _is_player_active(self):
268  """Report if the client is playing media."""
269  return self.statestatestatestate in {MediaPlayerState.PLAYING, MediaPlayerState.PAUSED}
270 
271  @property
273  """Get the active media type required by PlexAPI commands."""
274  if self.media_content_typemedia_content_typemedia_content_typemedia_content_type == MediaType.MUSIC:
275  return "music"
276 
277  return "video"
278 
279  @property
280  @needs_session
281  def session_key(self):
282  """Return current session key."""
283  return self.sessionsessionsessionsession.sessionKey
284 
285  @property
286  @needs_session
288  """Return the library name of playing media."""
289  return self.sessionsessionsessionsession.media_library_title
290 
291  @property
292  @needs_session
293  def media_content_id(self):
294  """Return the content ID of current playing media."""
295  return self.sessionsessionsessionsession.media_content_id
296 
297  @property
298  @needs_session
300  """Return the content type of current playing media."""
301  return self.sessionsessionsessionsession.media_content_type
302 
303  @property
304  @needs_session
306  """Return the content rating of current playing media."""
307  return self.sessionsessionsessionsession.media_content_rating
308 
309  @property
310  @needs_session
311  def media_artist(self):
312  """Return the artist of current playing media, music track only."""
313  return self.sessionsessionsessionsession.media_artist
314 
315  @property
316  @needs_session
317  def media_album_name(self):
318  """Return the album name of current playing media, music track only."""
319  return self.sessionsessionsessionsession.media_album_name
320 
321  @property
322  @needs_session
324  """Return the album artist of current playing media, music only."""
325  return self.sessionsessionsessionsession.media_album_artist
326 
327  @property
328  @needs_session
329  def media_track(self):
330  """Return the track number of current playing media, music only."""
331  return self.sessionsessionsessionsession.media_track
332 
333  @property
334  @needs_session
335  def media_duration(self):
336  """Return the duration of current playing media in seconds."""
337  return self.sessionsessionsessionsession.media_duration
338 
339  @property
340  @needs_session
341  def media_position(self):
342  """Return the duration of current playing media in seconds."""
343  return self.sessionsessionsessionsession.media_position
344 
345  @property
346  @needs_session
348  """When was the position of the current playing media valid."""
349  return self.sessionsessionsessionsession.media_position_updated_at
350 
351  @property
352  @needs_session
353  def media_image_url(self):
354  """Return the image URL of current playing media."""
355  return self.sessionsessionsessionsession.media_image_url
356 
357  @property
358  @needs_session
359  def media_summary(self):
360  """Return the summary of current playing media."""
361  return self.sessionsessionsessionsession.media_summary
362 
363  @property
364  @needs_session
365  def media_title(self):
366  """Return the title of current playing media."""
367  return self.sessionsessionsessionsession.media_title
368 
369  @property
370  @needs_session
371  def media_season(self):
372  """Return the season of current playing media (TV Show only)."""
373  return self.sessionsessionsessionsession.media_season
374 
375  @property
376  @needs_session
378  """Return the title of the series of current playing media."""
379  return self.sessionsessionsessionsession.media_series_title
380 
381  @property
382  @needs_session
383  def media_episode(self):
384  """Return the episode of current playing media (TV Show only)."""
385  return self.sessionsessionsessionsession.media_episode
386 
387  @property
388  def supported_features(self) -> MediaPlayerEntityFeature:
389  """Flag media player features that are supported."""
390  if self.devicedevice and "playback" in self._device_protocol_capabilities_device_protocol_capabilities:
391  return (
392  MediaPlayerEntityFeature.PAUSE
393  | MediaPlayerEntityFeature.PREVIOUS_TRACK
394  | MediaPlayerEntityFeature.NEXT_TRACK
395  | MediaPlayerEntityFeature.STOP
396  | MediaPlayerEntityFeature.SEEK
397  | MediaPlayerEntityFeature.VOLUME_SET
398  | MediaPlayerEntityFeature.PLAY
399  | MediaPlayerEntityFeature.PLAY_MEDIA
400  | MediaPlayerEntityFeature.VOLUME_MUTE
401  | MediaPlayerEntityFeature.BROWSE_MEDIA
402  )
403 
404  return (
405  MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA
406  )
407 
408  def set_volume_level(self, volume: float) -> None:
409  """Set volume level, range 0..1."""
410  if self.devicedevice and "playback" in self._device_protocol_capabilities_device_protocol_capabilities:
411  self.devicedevice.setVolume(int(volume * 100), self._active_media_plexapi_type_active_media_plexapi_type)
412  self._volume_level_volume_level = volume # store since we can't retrieve
413 
414  @property
415  def volume_level(self):
416  """Return the volume level of the client (0..1)."""
417  if (
418  self._is_player_active_is_player_active
419  and self.devicedevice
420  and "playback" in self._device_protocol_capabilities_device_protocol_capabilities
421  ):
422  return self._volume_level_volume_level
423  return None
424 
425  @property
426  def is_volume_muted(self):
427  """Return boolean if volume is currently muted."""
428  if self._is_player_active_is_player_active and self.devicedevice:
429  return self._volume_muted_volume_muted
430  return None
431 
432  def mute_volume(self, mute: bool) -> None:
433  """Mute the volume.
434 
435  Since we can't actually mute, we'll:
436  - On mute, store volume and set volume to 0
437  - On unmute, set volume to previously stored volume
438  """
439  if not (self.devicedevice and "playback" in self._device_protocol_capabilities_device_protocol_capabilities):
440  return
441 
442  self._volume_muted_volume_muted = mute
443  if mute:
444  self._previous_volume_level_previous_volume_level = self._volume_level_volume_level
445  self.set_volume_levelset_volume_levelset_volume_level(0)
446  else:
447  self.set_volume_levelset_volume_levelset_volume_level(self._previous_volume_level_previous_volume_level)
448 
449  def media_play(self) -> None:
450  """Send play command."""
451  if self.devicedevice and "playback" in self._device_protocol_capabilities_device_protocol_capabilities:
452  self.devicedevice.play(self._active_media_plexapi_type_active_media_plexapi_type)
453 
454  def media_pause(self) -> None:
455  """Send pause command."""
456  if self.devicedevice and "playback" in self._device_protocol_capabilities_device_protocol_capabilities:
457  self.devicedevice.pause(self._active_media_plexapi_type_active_media_plexapi_type)
458 
459  def media_stop(self) -> None:
460  """Send stop command."""
461  if self.devicedevice and "playback" in self._device_protocol_capabilities_device_protocol_capabilities:
462  self.devicedevice.stop(self._active_media_plexapi_type_active_media_plexapi_type)
463 
464  def media_seek(self, position: float) -> None:
465  """Send the seek command."""
466  if self.devicedevice and "playback" in self._device_protocol_capabilities_device_protocol_capabilities:
467  self.devicedevice.seekTo(position * 1000, self._active_media_plexapi_type_active_media_plexapi_type)
468 
469  def media_next_track(self) -> None:
470  """Send next track command."""
471  if self.devicedevice and "playback" in self._device_protocol_capabilities_device_protocol_capabilities:
472  self.devicedevice.skipNext(self._active_media_plexapi_type_active_media_plexapi_type)
473 
474  def media_previous_track(self) -> None:
475  """Send previous track command."""
476  if self.devicedevice and "playback" in self._device_protocol_capabilities_device_protocol_capabilities:
477  self.devicedevice.skipPrevious(self._active_media_plexapi_type_active_media_plexapi_type)
478 
480  self, media_type: MediaType | str, media_id: str, **kwargs: Any
481  ) -> None:
482  """Play a piece of media."""
483  if not (self.devicedevice and "playback" in self._device_protocol_capabilities_device_protocol_capabilities):
484  raise HomeAssistantError(
485  f"Client is not currently accepting playback controls: {self.name}"
486  )
487 
488  result = process_plex_payload(
489  self.hasshass, media_type, media_id, default_plex_server=self.plex_serverplex_server
490  )
491  _LOGGER.debug("Attempting to play %s on %s", result.media, self.namename)
492 
493  try:
494  self.devicedevice.playMedia(result.media, offset=result.offset)
495  except requests.exceptions.ConnectTimeout as exc:
496  raise HomeAssistantError(
497  f"Request failed when playing on {self.name}"
498  ) from exc
499 
500  @property
502  """Return the scene state attributes."""
503  attributes = {}
504  for attr in (
505  "media_content_rating",
506  "media_library_title",
507  "player_source",
508  "media_summary",
509  "username",
510  ):
511  if value := getattr(self, attr, None):
512  attributes[attr] = value
513 
514  return attributes
515 
516  @property
517  def device_info(self) -> DeviceInfo | None:
518  """Return a device description for device registry."""
519  if self.machine_identifiermachine_identifier is None:
520  return None
521 
522  if self.device_productdevice_product in TRANSIENT_DEVICE_MODELS:
523  return DeviceInfo(
524  identifiers={(DOMAIN, "plex.tv-clients")},
525  name="Plex Client Service",
526  manufacturer="Plex",
527  model="Plex Clients",
528  entry_type=DeviceEntryType.SERVICE,
529  )
530 
531  return DeviceInfo(
532  identifiers={(DOMAIN, self.machine_identifiermachine_identifier)},
533  manufacturer=self.device_platformdevice_platform or "Plex",
534  model=self.device_productdevice_product or self.device_makedevice_make,
535  # Instead of setting the device name to the entity name, plex
536  # should be updated to set has_entity_name = True, and set the entity
537  # name to None
538  name=cast(str | None, self.namename),
539  sw_version=self.device_versiondevice_version,
540  via_device=(DOMAIN, self.plex_serverplex_server.machine_identifier),
541  )
542 
544  self,
545  media_content_type: MediaType | str | None = None,
546  media_content_id: str | None = None,
547  ) -> BrowseMedia:
548  """Implement the websocket media browsing helper."""
549  is_internal = is_internal_request(self.hasshass)
550  return await self.hasshass.async_add_executor_job(
551  browse_media,
552  self.hasshass,
553  is_internal,
554  media_content_type,
555  media_content_id,
556  )
None play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
def __init__(self, plex_server, device, player_source, session=None)
def async_refresh_media_player(self, device, session, source)
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
None async_schedule_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:1265
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
str|UndefinedType|None name(self)
Definition: entity.py:738
PlexData get_plex_data(HomeAssistant hass)
Definition: helpers.py:29
PlexServer get_plex_server(HomeAssistant hass, str server_id)
Definition: helpers.py:34
def _async_add_entities(hass, registry, async_add_entities, server_id, new_entities)
Definition: media_player.py:89
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: media_player.py:72
PlexMediaSearchResult process_plex_payload(HomeAssistant hass, str content_type, str content_id, PlexServer|None default_plex_server=None, bool supports_playqueues=True)
Definition: services.py:124
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
bool is_internal_request(HomeAssistant hass)
Definition: network.py:31