Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for Apple TV media player."""
2 
3 from __future__ import annotations
4 
5 from datetime import datetime
6 import logging
7 from typing import Any
8 
9 from pyatv import exceptions
10 from pyatv.const import (
11  DeviceState,
12  FeatureName,
13  FeatureState,
14  MediaType as AppleMediaType,
15  PowerState,
16  RepeatState,
17  ShuffleState,
18 )
19 from pyatv.helpers import is_streamable
20 from pyatv.interface import (
21  AppleTV,
22  AudioListener,
23  OutputDevice,
24  Playing,
25  PowerListener,
26  PushListener,
27  PushUpdater,
28 )
29 
30 from homeassistant.components import media_source
32  BrowseMedia,
33  MediaPlayerEntity,
34  MediaPlayerEntityFeature,
35  MediaPlayerState,
36  MediaType,
37  RepeatMode,
38  async_process_play_media_url,
39 )
40 from homeassistant.const import CONF_NAME
41 from homeassistant.core import HomeAssistant, callback
42 from homeassistant.helpers.entity_platform import AddEntitiesCallback
43 import homeassistant.util.dt as dt_util
44 
45 from . import AppleTvConfigEntry, AppleTVManager
46 from .browse_media import build_app_list
47 from .entity import AppleTVEntity
48 
49 _LOGGER = logging.getLogger(__name__)
50 
51 PARALLEL_UPDATES = 0
52 
53 # We always consider these to be supported
54 SUPPORT_BASE = MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF
55 
56 # This is the "optimistic" view of supported features and will be returned until the
57 # actual set of supported feature have been determined (will always be all or a subset
58 # of these).
59 SUPPORT_APPLE_TV = (
60  SUPPORT_BASE
61  | MediaPlayerEntityFeature.BROWSE_MEDIA
62  | MediaPlayerEntityFeature.PLAY_MEDIA
63  | MediaPlayerEntityFeature.PAUSE
64  | MediaPlayerEntityFeature.PLAY
65  | MediaPlayerEntityFeature.SEEK
66  | MediaPlayerEntityFeature.STOP
67  | MediaPlayerEntityFeature.NEXT_TRACK
68  | MediaPlayerEntityFeature.PREVIOUS_TRACK
69  | MediaPlayerEntityFeature.VOLUME_SET
70  | MediaPlayerEntityFeature.VOLUME_STEP
71  | MediaPlayerEntityFeature.REPEAT_SET
72  | MediaPlayerEntityFeature.SHUFFLE_SET
73 )
74 
75 
76 # Map features in pyatv to Home Assistant
77 SUPPORT_FEATURE_MAPPING = {
78  FeatureName.PlayUrl: MediaPlayerEntityFeature.BROWSE_MEDIA
79  | MediaPlayerEntityFeature.PLAY_MEDIA,
80  FeatureName.StreamFile: MediaPlayerEntityFeature.BROWSE_MEDIA
81  | MediaPlayerEntityFeature.PLAY_MEDIA,
82  FeatureName.Pause: MediaPlayerEntityFeature.PAUSE,
83  FeatureName.Play: MediaPlayerEntityFeature.PLAY,
84  FeatureName.SetPosition: MediaPlayerEntityFeature.SEEK,
85  FeatureName.Stop: MediaPlayerEntityFeature.STOP,
86  FeatureName.Next: MediaPlayerEntityFeature.NEXT_TRACK,
87  FeatureName.Previous: MediaPlayerEntityFeature.PREVIOUS_TRACK,
88  FeatureName.VolumeUp: MediaPlayerEntityFeature.VOLUME_STEP,
89  FeatureName.VolumeDown: MediaPlayerEntityFeature.VOLUME_STEP,
90  FeatureName.SetRepeat: MediaPlayerEntityFeature.REPEAT_SET,
91  FeatureName.SetShuffle: MediaPlayerEntityFeature.SHUFFLE_SET,
92  FeatureName.SetVolume: MediaPlayerEntityFeature.VOLUME_SET,
93  FeatureName.AppList: MediaPlayerEntityFeature.BROWSE_MEDIA
94  | MediaPlayerEntityFeature.SELECT_SOURCE,
95  FeatureName.LaunchApp: MediaPlayerEntityFeature.BROWSE_MEDIA
96  | MediaPlayerEntityFeature.SELECT_SOURCE,
97 }
98 
99 
101  hass: HomeAssistant,
102  config_entry: AppleTvConfigEntry,
103  async_add_entities: AddEntitiesCallback,
104 ) -> None:
105  """Load Apple TV media player based on a config entry."""
106  name: str = config_entry.data[CONF_NAME]
107  assert config_entry.unique_id is not None
108  manager = config_entry.runtime_data
109  async_add_entities([AppleTvMediaPlayer(name, config_entry.unique_id, manager)])
110 
111 
113  AppleTVEntity, MediaPlayerEntity, PowerListener, AudioListener, PushListener
114 ):
115  """Representation of an Apple TV media player."""
116 
117  _attr_supported_features = SUPPORT_APPLE_TV
118 
119  def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None:
120  """Initialize the Apple TV media player."""
121  super().__init__(name, identifier, manager)
122  self._playing_playing: Playing | None = None
123  self._app_list_app_list: dict[str, str] = {}
124 
125  @callback
126  def async_device_connected(self, atv: AppleTV) -> None:
127  """Handle when connection is made to device."""
128  # NB: Do not use _is_feature_available here as it only works when playing
129  if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
130  atv.push_updater.listener = self
131  atv.push_updater.start()
132 
133  self._attr_supported_features_attr_supported_features = SUPPORT_BASE
134 
135  # Determine the actual set of supported features. All features not reported as
136  # "Unsupported" are considered here as the state of such a feature can never
137  # change after a connection has been established, i.e. an unsupported feature
138  # can never change to be supported.
139  all_features = atv.features.all_features()
140  for feature_name, support_flag in SUPPORT_FEATURE_MAPPING.items():
141  feature_info = all_features.get(feature_name)
142  if feature_info and feature_info.state != FeatureState.Unsupported:
143  self._attr_supported_features_attr_supported_features |= support_flag
144 
145  # No need to schedule state update here as that will happen when the first
146  # metadata update arrives (sometime very soon after this callback returns)
147 
148  # Listen to power updates
149  atv.power.listener = self
150 
151  # Listen to volume updates
152  atv.audio.listener = self
153 
154  if atv.features.in_state(FeatureState.Available, FeatureName.AppList):
155  self.managermanager.config_entry.async_create_task(
156  self.hasshass, self._update_app_list_update_app_list(), eager_start=True
157  )
158 
159  async def _update_app_list(self) -> None:
160  _LOGGER.debug("Updating app list")
161  if not self.atvatv:
162  return
163  try:
164  apps = await self.atvatv.apps.app_list()
165  except exceptions.NotSupportedError:
166  _LOGGER.error("Listing apps is not supported")
167  except exceptions.ProtocolError:
168  _LOGGER.exception("Failed to update app list")
169  else:
170  self._app_list_app_list = {
171  app_name: app.identifier
172  for app in sorted(apps, key=lambda app: (app.name or "").lower())
173  if (app_name := app.name) is not None
174  }
175  self.async_write_ha_stateasync_write_ha_state()
176 
177  @callback
178  def async_device_disconnected(self) -> None:
179  """Handle when connection was lost to device."""
180  self._attr_supported_features_attr_supported_features = SUPPORT_APPLE_TV
181 
182  @property
183  def state(self) -> MediaPlayerState | None:
184  """Return the state of the device."""
185  if self.managermanager.is_connecting:
186  return None
187  if self.atvatv is None:
188  return MediaPlayerState.OFF
189  if (
190  self._is_feature_available_is_feature_available(FeatureName.PowerState)
191  and self.atvatv.power.power_state == PowerState.Off
192  ):
193  return MediaPlayerState.STANDBY
194  if self._playing_playing:
195  state = self._playing_playing.device_state
196  if state in (DeviceState.Idle, DeviceState.Loading):
197  return MediaPlayerState.IDLE
198  if state == DeviceState.Playing:
199  return MediaPlayerState.PLAYING
200  if state in (DeviceState.Paused, DeviceState.Seeking, DeviceState.Stopped):
201  return MediaPlayerState.PAUSED
202  return MediaPlayerState.STANDBY # Bad or unknown state?
203  return None
204 
205  @callback
206  def playstatus_update(self, updater: PushUpdater, playstatus: Playing) -> None:
207  """Print what is currently playing when it changes.
208 
209  This is a callback function from pyatv.interface.PushListener.
210  """
211  self._playing_playing = playstatus
212  self.async_write_ha_stateasync_write_ha_state()
213 
214  @callback
215  def playstatus_error(self, updater: PushUpdater, exception: Exception) -> None:
216  """Inform about an error and restart push updates.
217 
218  This is a callback function from pyatv.interface.PushListener.
219  """
220  _LOGGER.warning("A %s error occurred: %s", exception.__class__, exception)
221  self._playing_playing = None
222  self.async_write_ha_stateasync_write_ha_state()
223 
224  @callback
225  def powerstate_update(self, old_state: PowerState, new_state: PowerState) -> None:
226  """Update power state when it changes.
227 
228  This is a callback function from pyatv.interface.PowerListener.
229  """
230  self.async_write_ha_stateasync_write_ha_state()
231 
232  @callback
233  def volume_update(self, old_level: float, new_level: float) -> None:
234  """Update volume when it changes.
235 
236  This is a callback function from pyatv.interface.AudioListener.
237  """
238  self.async_write_ha_stateasync_write_ha_state()
239 
240  @callback
242  self, old_devices: list[OutputDevice], new_devices: list[OutputDevice]
243  ) -> None:
244  """Output devices were updated.
245 
246  This is a callback function from pyatv.interface.AudioListener.
247  """
248 
249  @property
250  def app_id(self) -> str | None:
251  """ID of the current running app."""
252  if (
253  self.atvatv
254  and self._is_feature_available_is_feature_available(FeatureName.App)
255  and (app := self.atvatv.metadata.app) is not None
256  ):
257  return app.identifier
258  return None
259 
260  @property
261  def app_name(self) -> str | None:
262  """Name of the current running app."""
263  if (
264  self.atvatv
265  and self._is_feature_available_is_feature_available(FeatureName.App)
266  and (app := self.atvatv.metadata.app) is not None
267  ):
268  return app.name
269  return None
270 
271  @property
272  def source_list(self) -> list[str]:
273  """List of available input sources."""
274  return list(self._app_list_app_list.keys())
275 
276  @property
277  def media_content_type(self) -> MediaType | None:
278  """Content type of current playing media."""
279  if self._playing_playing:
280  return {
281  AppleMediaType.Video: MediaType.VIDEO,
282  AppleMediaType.Music: MediaType.MUSIC,
283  AppleMediaType.TV: MediaType.TVSHOW,
284  }.get(self._playing_playing.media_type)
285  return None
286 
287  @property
288  def media_content_id(self) -> str | None:
289  """Content ID of current playing media."""
290  if self._playing_playing:
291  return self._playing_playing.content_identifier
292  return None
293 
294  @property
295  def volume_level(self) -> float | None:
296  """Volume level of the media player (0..1)."""
297  if self.atvatv and self._is_feature_available_is_feature_available(FeatureName.Volume):
298  return self.atvatv.audio.volume / 100.0 # from percent
299  return None
300 
301  @property
302  def media_duration(self) -> int | None:
303  """Duration of current playing media in seconds."""
304  if self._playing_playing:
305  return self._playing_playing.total_time
306  return None
307 
308  @property
309  def media_position(self) -> int | None:
310  """Position of current playing media in seconds."""
311  if self._playing_playing:
312  return self._playing_playing.position
313  return None
314 
315  @property
316  def media_position_updated_at(self) -> datetime | None:
317  """Last valid time of media position."""
318  if self.statestatestatestatestate in {MediaPlayerState.PLAYING, MediaPlayerState.PAUSED}:
319  return dt_util.utcnow()
320  return None
321 
322  async def async_play_media(
323  self, media_type: MediaType | str, media_id: str, **kwargs: Any
324  ) -> None:
325  """Send the play_media command to the media player."""
326  # If input (file) has a file format supported by pyatv, then stream it with
327  # RAOP. Otherwise try to play it with regular AirPlay.
328  if not self.atvatv:
329  return
330  if media_type in {MediaType.APP, MediaType.URL}:
331  await self.atvatv.apps.launch_app(media_id)
332  return
333 
334  if media_source.is_media_source_id(media_id):
335  play_item = await media_source.async_resolve_media(
336  self.hasshass, media_id, self.entity_identity_id
337  )
338  media_id = async_process_play_media_url(self.hasshass, play_item.url)
339  media_type = MediaType.MUSIC
340 
341  if self._is_feature_available_is_feature_available(FeatureName.StreamFile) and (
342  media_type == MediaType.MUSIC or await is_streamable(media_id)
343  ):
344  _LOGGER.debug("Streaming %s via RAOP", media_id)
345  await self.atvatv.stream.stream_file(media_id)
346  elif self._is_feature_available_is_feature_available(FeatureName.PlayUrl):
347  _LOGGER.debug("Playing %s via AirPlay", media_id)
348  await self.atvatv.stream.play_url(media_id)
349  else:
350  _LOGGER.error("Media streaming is not possible with current configuration")
351 
352  @property
353  def media_image_hash(self) -> str | None:
354  """Hash value for media image."""
355  state = self.statestatestatestatestate
356  if (
357  self.atvatv
358  and self._playing_playing
359  and self._is_feature_available_is_feature_available(FeatureName.Artwork)
360  and state not in {None, MediaPlayerState.OFF, MediaPlayerState.IDLE}
361  ):
362  return self.atvatv.metadata.artwork_id
363  return None
364 
365  async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
366  """Fetch media image of current playing image."""
367  state = self.statestatestatestatestate
368  if (
369  self.atvatv
370  and self._playing_playing
371  and state not in {MediaPlayerState.OFF, MediaPlayerState.IDLE}
372  ):
373  artwork = await self.atvatv.metadata.artwork()
374  if artwork:
375  return artwork.bytes, artwork.mimetype
376 
377  return None, None
378 
379  @property
380  def media_title(self) -> str | None:
381  """Title of current playing media."""
382  if self._playing_playing:
383  return self._playing_playing.title
384  return None
385 
386  @property
387  def media_artist(self) -> str | None:
388  """Artist of current playing media, music track only."""
389  if self._playing_playing and self._is_feature_available_is_feature_available(FeatureName.Artist):
390  return self._playing_playing.artist
391  return None
392 
393  @property
394  def media_album_name(self) -> str | None:
395  """Album name of current playing media, music track only."""
396  if self._playing_playing and self._is_feature_available_is_feature_available(FeatureName.Album):
397  return self._playing_playing.album
398  return None
399 
400  @property
401  def media_series_title(self) -> str | None:
402  """Title of series of current playing media, TV show only."""
403  if self._playing_playing and self._is_feature_available_is_feature_available(FeatureName.SeriesName):
404  return self._playing_playing.series_name
405  return None
406 
407  @property
408  def media_season(self) -> str | None:
409  """Season of current playing media, TV show only."""
410  if self._playing_playing and self._is_feature_available_is_feature_available(FeatureName.SeasonNumber):
411  return str(self._playing_playing.season_number)
412  return None
413 
414  @property
415  def media_episode(self) -> str | None:
416  """Episode of current playing media, TV show only."""
417  if self._playing_playing and self._is_feature_available_is_feature_available(FeatureName.EpisodeNumber):
418  return str(self._playing_playing.episode_number)
419  return None
420 
421  @property
422  def repeat(self) -> RepeatMode | None:
423  """Return current repeat mode."""
424  if (
425  self._playing_playing
426  and self._is_feature_available_is_feature_available(FeatureName.Repeat)
427  and (repeat := self._playing_playing.repeat)
428  ):
429  return {
430  RepeatState.Track: RepeatMode.ONE,
431  RepeatState.All: RepeatMode.ALL,
432  }.get(repeat, RepeatMode.OFF)
433  return None
434 
435  @property
436  def shuffle(self) -> bool | None:
437  """Boolean if shuffle is enabled."""
438  if self._playing_playing and self._is_feature_available_is_feature_available(FeatureName.Shuffle):
439  return self._playing_playing.shuffle != ShuffleState.Off
440  return None
441 
442  def _is_feature_available(self, feature: FeatureName) -> bool:
443  """Return if a feature is available."""
444  if self.atvatv and self._playing_playing:
445  return self.atvatv.features.in_state(FeatureState.Available, feature)
446  return False
447 
449  self,
450  media_content_type: MediaType | str | None = None,
451  media_content_id: str | None = None,
452  ) -> BrowseMedia:
453  """Implement the websocket media browsing helper."""
454  if media_content_id == "apps" or (
455  # If we can't stream files or URLs, we can't browse media.
456  # In that case the `BROWSE_MEDIA` feature was added because of AppList/LaunchApp
457  not self._is_feature_available_is_feature_available(FeatureName.PlayUrl)
458  and not self._is_feature_available_is_feature_available(FeatureName.StreamFile)
459  ):
460  return build_app_list(self._app_list_app_list)
461 
462  if self._app_list_app_list:
463  kwargs = {}
464  else:
465  # If it has no apps, assume it has no display
466  kwargs = {
467  "content_filter": lambda item: item.media_content_type.startswith(
468  "audio/"
469  ),
470  }
471 
472  cur_item = await media_source.async_browse_media(
473  self.hasshass, media_content_id, **kwargs
474  )
475 
476  # If media content id is not None, we're browsing into a media source
477  if media_content_id is not None:
478  return cur_item
479 
480  # Add app item if we have one
481  if self._app_list_app_list and cur_item.children and isinstance(cur_item.children, list):
482  cur_item.children.insert(0, build_app_list(self._app_list_app_list))
483 
484  return cur_item
485 
486  async def async_turn_on(self) -> None:
487  """Turn the media player on."""
488  if self.atvatv and self._is_feature_available_is_feature_available(FeatureName.TurnOn):
489  await self.atvatv.power.turn_on()
490 
491  async def async_turn_off(self) -> None:
492  """Turn the media player off."""
493  if (
494  self.atvatv
495  and (self._is_feature_available_is_feature_available(FeatureName.TurnOff))
496  and (
497  not self._is_feature_available_is_feature_available(FeatureName.PowerState)
498  or self.atvatv.power.power_state == PowerState.On
499  )
500  ):
501  await self.atvatv.power.turn_off()
502 
503  async def async_media_play_pause(self) -> None:
504  """Pause media on media player."""
505  if self.atvatv and self._playing_playing:
506  await self.atvatv.remote_control.play_pause()
507 
508  async def async_media_play(self) -> None:
509  """Play media."""
510  if self.atvatv:
511  await self.atvatv.remote_control.play()
512 
513  async def async_media_stop(self) -> None:
514  """Stop the media player."""
515  if self.atvatv:
516  await self.atvatv.remote_control.stop()
517 
518  async def async_media_pause(self) -> None:
519  """Pause the media player."""
520  if self.atvatv:
521  await self.atvatv.remote_control.pause()
522 
523  async def async_media_next_track(self) -> None:
524  """Send next track command."""
525  if self.atvatv:
526  await self.atvatv.remote_control.next()
527 
528  async def async_media_previous_track(self) -> None:
529  """Send previous track command."""
530  if self.atvatv:
531  await self.atvatv.remote_control.previous()
532 
533  async def async_media_seek(self, position: float) -> None:
534  """Send seek command."""
535  if self.atvatv:
536  await self.atvatv.remote_control.set_position(round(position))
537 
538  async def async_volume_up(self) -> None:
539  """Turn volume up for media player."""
540  if self.atvatv:
541  await self.atvatv.audio.volume_up()
542 
543  async def async_volume_down(self) -> None:
544  """Turn volume down for media player."""
545  if self.atvatv:
546  await self.atvatv.audio.volume_down()
547 
548  async def async_set_volume_level(self, volume: float) -> None:
549  """Set volume level, range 0..1."""
550  if self.atvatv:
551  # pyatv expects volume in percent
552  await self.atvatv.audio.set_volume(volume * 100.0)
553 
554  async def async_set_repeat(self, repeat: RepeatMode) -> None:
555  """Set repeat mode."""
556  if self.atvatv:
557  mode = {
558  RepeatMode.ONE: RepeatState.Track,
559  RepeatMode.ALL: RepeatState.All,
560  }.get(repeat, RepeatState.Off)
561  await self.atvatv.remote_control.set_repeat(mode)
562 
563  async def async_set_shuffle(self, shuffle: bool) -> None:
564  """Enable/disable shuffle mode."""
565  if self.atvatv:
566  await self.atvatv.remote_control.set_shuffle(
567  ShuffleState.Songs if shuffle else ShuffleState.Off
568  )
569 
570  async def async_select_source(self, source: str) -> None:
571  """Select input source."""
572  if self.atvatv:
573  if app_id := self._app_list_app_list.get(source):
574  await self.atvatv.apps.launch_app(app_id)
None outputdevices_update(self, list[OutputDevice] old_devices, list[OutputDevice] new_devices)
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
None __init__(self, str name, str identifier, AppleTVManager manager)
None playstatus_update(self, PushUpdater updater, Playing playstatus)
None playstatus_error(self, PushUpdater updater, Exception exception)
None volume_update(self, float old_level, float new_level)
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
None powerstate_update(self, PowerState old_state, PowerState new_state)
BrowseMedia build_app_list(dict[str, str] app_list)
Definition: browse_media.py:8
None async_setup_entry(HomeAssistant hass, AppleTvConfigEntry config_entry, AddEntitiesCallback async_add_entities)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
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