Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support to interface with Sonos players."""
2 
3 from __future__ import annotations
4 
5 import datetime
6 from functools import partial
7 import logging
8 from typing import Any
9 
10 from soco import SoCo, alarms
11 from soco.core import (
12  MUSIC_SRC_LINE_IN,
13  MUSIC_SRC_RADIO,
14  PLAY_MODE_BY_MEANING,
15  PLAY_MODES,
16 )
17 from soco.data_structures import DidlFavorite, DidlMusicTrack
18 from soco.ms_data_structures import MusicServiceItem
19 from sonos_websocket.exception import SonosWebsocketError
20 import voluptuous as vol
21 
22 from homeassistant.components import media_source, spotify
24  ATTR_INPUT_SOURCE,
25  ATTR_MEDIA_ALBUM_NAME,
26  ATTR_MEDIA_ANNOUNCE,
27  ATTR_MEDIA_ARTIST,
28  ATTR_MEDIA_CONTENT_ID,
29  ATTR_MEDIA_ENQUEUE,
30  ATTR_MEDIA_TITLE,
31  BrowseMedia,
32  MediaPlayerDeviceClass,
33  MediaPlayerEnqueue,
34  MediaPlayerEntity,
35  MediaPlayerEntityFeature,
36  MediaPlayerState,
37  MediaType,
38  RepeatMode,
39  async_process_play_media_url,
40 )
41 from homeassistant.components.plex import PLEX_URI_SCHEME
42 from homeassistant.components.plex.services import process_plex_payload
43 from homeassistant.config_entries import ConfigEntry
44 from homeassistant.const import ATTR_TIME
45 from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
46 from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
47 from homeassistant.helpers import config_validation as cv, entity_platform, service
48 from homeassistant.helpers.dispatcher import async_dispatcher_connect
49 from homeassistant.helpers.entity_platform import AddEntitiesCallback
50 from homeassistant.helpers.event import async_call_later
51 
52 from . import UnjoinData, media_browser
53 from .const import (
54  DATA_SONOS,
55  DOMAIN as SONOS_DOMAIN,
56  MEDIA_TYPES_TO_SONOS,
57  MODELS_LINEIN_AND_TV,
58  MODELS_LINEIN_ONLY,
59  MODELS_TV_ONLY,
60  PLAYABLE_MEDIA_TYPES,
61  SONOS_CREATE_MEDIA_PLAYER,
62  SONOS_MEDIA_UPDATED,
63  SONOS_STATE_PLAYING,
64  SONOS_STATE_TRANSITIONING,
65  SOURCE_LINEIN,
66  SOURCE_TV,
67 )
68 from .entity import SonosEntity
69 from .helpers import soco_error
70 from .speaker import SonosMedia, SonosSpeaker
71 
72 _LOGGER = logging.getLogger(__name__)
73 
74 LONG_SERVICE_TIMEOUT = 30.0
75 UNJOIN_SERVICE_TIMEOUT = 0.1
76 VOLUME_INCREMENT = 2
77 
78 REPEAT_TO_SONOS = {
79  RepeatMode.OFF: False,
80  RepeatMode.ALL: True,
81  RepeatMode.ONE: "ONE",
82 }
83 
84 SONOS_TO_REPEAT = {meaning: mode for mode, meaning in REPEAT_TO_SONOS.items()}
85 
86 UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"]
87 ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"]
88 
89 SERVICE_SNAPSHOT = "snapshot"
90 SERVICE_RESTORE = "restore"
91 SERVICE_SET_TIMER = "set_sleep_timer"
92 SERVICE_CLEAR_TIMER = "clear_sleep_timer"
93 SERVICE_UPDATE_ALARM = "update_alarm"
94 SERVICE_PLAY_QUEUE = "play_queue"
95 SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue"
96 SERVICE_GET_QUEUE = "get_queue"
97 
98 ATTR_SLEEP_TIME = "sleep_time"
99 ATTR_ALARM_ID = "alarm_id"
100 ATTR_VOLUME = "volume"
101 ATTR_ENABLED = "enabled"
102 ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones"
103 ATTR_MASTER = "master"
104 ATTR_WITH_GROUP = "with_group"
105 ATTR_QUEUE_POSITION = "queue_position"
106 
107 
109  hass: HomeAssistant,
110  config_entry: ConfigEntry,
111  async_add_entities: AddEntitiesCallback,
112 ) -> None:
113  """Set up Sonos from a config entry."""
114  platform = entity_platform.async_get_current_platform()
115 
116  @callback
117  def async_create_entities(speaker: SonosSpeaker) -> None:
118  """Handle device discovery and create entities."""
119  _LOGGER.debug("Creating media_player on %s", speaker.zone_name)
121 
122  @service.verify_domain_control(hass, SONOS_DOMAIN)
123  async def async_service_handle(service_call: ServiceCall) -> None:
124  """Handle dispatched services."""
125  assert platform is not None
126  entities = await platform.async_extract_from_service(service_call)
127 
128  if not entities:
129  return
130 
131  speakers = []
132  for entity in entities:
133  assert isinstance(entity, SonosMediaPlayerEntity)
134  speakers.append(entity.speaker)
135 
136  if service_call.service == SERVICE_SNAPSHOT:
137  await SonosSpeaker.snapshot_multi(
138  hass, speakers, service_call.data[ATTR_WITH_GROUP]
139  )
140  elif service_call.service == SERVICE_RESTORE:
141  await SonosSpeaker.restore_multi(
142  hass, speakers, service_call.data[ATTR_WITH_GROUP]
143  )
144 
145  config_entry.async_on_unload(
146  async_dispatcher_connect(hass, SONOS_CREATE_MEDIA_PLAYER, async_create_entities)
147  )
148 
149  join_unjoin_schema = cv.make_entity_service_schema(
150  {vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean}
151  )
152 
153  hass.services.async_register(
154  SONOS_DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema
155  )
156 
157  hass.services.async_register(
158  SONOS_DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema
159  )
160 
161  platform.async_register_entity_service(
162  SERVICE_SET_TIMER,
163  {
164  vol.Required(ATTR_SLEEP_TIME): vol.All(
165  vol.Coerce(int), vol.Range(min=0, max=86399)
166  )
167  },
168  "set_sleep_timer",
169  )
170 
171  platform.async_register_entity_service(
172  SERVICE_CLEAR_TIMER, None, "clear_sleep_timer"
173  )
174 
175  platform.async_register_entity_service(
176  SERVICE_UPDATE_ALARM,
177  {
178  vol.Required(ATTR_ALARM_ID): cv.positive_int,
179  vol.Optional(ATTR_TIME): cv.time,
180  vol.Optional(ATTR_VOLUME): cv.small_float,
181  vol.Optional(ATTR_ENABLED): cv.boolean,
182  vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean,
183  },
184  "set_alarm",
185  )
186 
187  platform.async_register_entity_service(
188  SERVICE_PLAY_QUEUE,
189  {vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
190  "play_queue",
191  )
192 
193  platform.async_register_entity_service(
194  SERVICE_REMOVE_FROM_QUEUE,
195  {vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
196  "remove_from_queue",
197  )
198 
199  platform.async_register_entity_service(
200  SERVICE_GET_QUEUE,
201  None,
202  "get_queue",
203  supports_response=SupportsResponse.ONLY,
204  )
205 
206 
208  """Representation of a Sonos entity."""
209 
210  _attr_name = None
211  _attr_supported_features = (
212  MediaPlayerEntityFeature.BROWSE_MEDIA
213  | MediaPlayerEntityFeature.CLEAR_PLAYLIST
214  | MediaPlayerEntityFeature.GROUPING
215  | MediaPlayerEntityFeature.MEDIA_ANNOUNCE
216  | MediaPlayerEntityFeature.MEDIA_ENQUEUE
217  | MediaPlayerEntityFeature.NEXT_TRACK
218  | MediaPlayerEntityFeature.PAUSE
219  | MediaPlayerEntityFeature.PLAY
220  | MediaPlayerEntityFeature.PLAY_MEDIA
221  | MediaPlayerEntityFeature.PREVIOUS_TRACK
222  | MediaPlayerEntityFeature.REPEAT_SET
223  | MediaPlayerEntityFeature.SEEK
224  | MediaPlayerEntityFeature.SELECT_SOURCE
225  | MediaPlayerEntityFeature.SHUFFLE_SET
226  | MediaPlayerEntityFeature.STOP
227  | MediaPlayerEntityFeature.VOLUME_MUTE
228  | MediaPlayerEntityFeature.VOLUME_SET
229  )
230  _attr_media_content_type = MediaType.MUSIC
231  _attr_device_class = MediaPlayerDeviceClass.SPEAKER
232 
233  def __init__(self, speaker: SonosSpeaker) -> None:
234  """Initialize the media player entity."""
235  super().__init__(speaker)
236  self._attr_unique_id_attr_unique_id = self.socosoco.uid
237 
238  async def async_added_to_hass(self) -> None:
239  """Handle common setup when added to hass."""
240  await super().async_added_to_hass()
241  self.async_on_removeasync_on_remove(
243  self.hasshass,
244  SONOS_MEDIA_UPDATED,
245  self.async_write_media_stateasync_write_media_state,
246  )
247  )
248 
249  @callback
250  def async_write_media_state(self, uid: str) -> None:
251  """Write media state if the provided UID is coordinator of this speaker."""
252  if self.coordinatorcoordinator.uid == uid:
253  self.async_write_ha_stateasync_write_ha_state()
254 
255  @property
256  def available(self) -> bool:
257  """Return if the media_player is available."""
258  return (
259  self.speakerspeaker.available
260  and bool(self.speakerspeaker.sonos_group_entities)
261  and self.mediamedia.playback_status is not None
262  )
263 
264  @property
265  def coordinator(self) -> SonosSpeaker:
266  """Return the current coordinator SonosSpeaker."""
267  return self.speakerspeaker.coordinator or self.speakerspeaker
268 
269  @property
270  def group_members(self) -> list[str] | None:
271  """List of entity_ids which are currently grouped together."""
272  return self.speakerspeaker.sonos_group_entities
273 
274  def __hash__(self) -> int:
275  """Return a hash of self."""
276  return hash(self.unique_idunique_id)
277 
278  @property
279  def state(self) -> MediaPlayerState:
280  """Return the state of the entity."""
281  if self.mediamedia.playback_status in (
282  "PAUSED_PLAYBACK",
283  "STOPPED",
284  ):
285  # Sonos can consider itself "paused" but without having media loaded
286  # (happens if playing Spotify and via Spotify app
287  # you pick another device to play on)
288  if self.mediamedia.title is None:
289  return MediaPlayerState.IDLE
290  return MediaPlayerState.PAUSED
291  if self.mediamedia.playback_status in (
292  SONOS_STATE_PLAYING,
293  SONOS_STATE_TRANSITIONING,
294  ):
295  return MediaPlayerState.PLAYING
296  return MediaPlayerState.IDLE
297 
298  async def _async_fallback_poll(self) -> None:
299  """Retrieve latest state by polling."""
300  await (
301  self.hasshass.data[DATA_SONOS].favorites[self.speakerspeaker.household_id].async_poll()
302  )
303  await self.hasshass.async_add_executor_job(self._update_update)
304 
305  def _update(self) -> None:
306  """Retrieve latest state by polling."""
307  self.speakerspeaker.update_groups()
308  self.speakerspeaker.update_volume()
309  if self.speakerspeaker.is_coordinator:
310  self.mediamedia.poll_media()
311 
312  @property
313  def volume_level(self) -> float | None:
314  """Volume level of the media player (0..1)."""
315  return self.speakerspeaker.volume and self.speakerspeaker.volume / 100
316 
317  @property
318  def is_volume_muted(self) -> bool | None:
319  """Return true if volume is muted."""
320  return self.speakerspeaker.muted
321 
322  @property
323  def shuffle(self) -> bool | None:
324  """Shuffling state."""
325  return PLAY_MODES[self.mediamedia.play_mode][0]
326 
327  @property
328  def repeat(self) -> RepeatMode | None:
329  """Return current repeat mode."""
330  sonos_repeat = PLAY_MODES[self.mediamedia.play_mode][1]
331  return SONOS_TO_REPEAT[sonos_repeat]
332 
333  @property
334  def media(self) -> SonosMedia:
335  """Return the SonosMedia object from the coordinator speaker."""
336  return self.coordinatorcoordinator.media
337 
338  @property
339  def media_content_id(self) -> str | None:
340  """Content id of current playing media."""
341  return self.mediamedia.uri
342 
343  @property
344  def media_duration(self) -> int | None:
345  """Duration of current playing media in seconds."""
346  return int(self.mediamedia.duration) if self.mediamedia.duration else None
347 
348  @property
349  def media_position(self) -> int | None:
350  """Position of current playing media in seconds."""
351  return self.mediamedia.position
352 
353  @property
354  def media_position_updated_at(self) -> datetime.datetime | None:
355  """When was the position of the current playing media valid."""
356  return self.mediamedia.position_updated_at
357 
358  @property
359  def media_image_url(self) -> str | None:
360  """Image url of current playing media."""
361  return self.mediamedia.image_url or None
362 
363  @property
364  def media_channel(self) -> str | None:
365  """Channel currently playing."""
366  return self.mediamedia.channel or None
367 
368  @property
369  def media_playlist(self) -> str | None:
370  """Title of playlist currently playing."""
371  return self.mediamedia.playlist_name
372 
373  @property
374  def media_artist(self) -> str | None:
375  """Artist of current playing media, music track only."""
376  return self.mediamedia.artist or None
377 
378  @property
379  def media_album_name(self) -> str | None:
380  """Album name of current playing media, music track only."""
381  return self.mediamedia.album_name or None
382 
383  @property
384  def media_title(self) -> str | None:
385  """Title of current playing media."""
386  return self.mediamedia.title or None
387 
388  @property
389  def source(self) -> str | None:
390  """Name of the current input source."""
391  return self.mediamedia.source_name or None
392 
393  @soco_error()
394  def volume_up(self) -> None:
395  """Volume up media player."""
396  self.socosoco.volume += VOLUME_INCREMENT
397 
398  @soco_error()
399  def volume_down(self) -> None:
400  """Volume down media player."""
401  self.socosoco.volume -= VOLUME_INCREMENT
402 
403  @soco_error()
404  def set_volume_level(self, volume: float) -> None:
405  """Set volume level, range 0..1."""
406  self.socosoco.volume = int(volume * 100)
407 
408  @soco_error(UPNP_ERRORS_TO_IGNORE)
409  def set_shuffle(self, shuffle: bool) -> None:
410  """Enable/Disable shuffle mode."""
411  sonos_shuffle = shuffle
412  sonos_repeat = PLAY_MODES[self.mediamedia.play_mode][1]
413  self.coordinatorcoordinator.soco.play_mode = PLAY_MODE_BY_MEANING[
414  (sonos_shuffle, sonos_repeat)
415  ]
416 
417  @soco_error(UPNP_ERRORS_TO_IGNORE)
418  def set_repeat(self, repeat: RepeatMode) -> None:
419  """Set repeat mode."""
420  sonos_shuffle = PLAY_MODES[self.mediamedia.play_mode][0]
421  sonos_repeat = REPEAT_TO_SONOS[repeat]
422  self.coordinatorcoordinator.soco.play_mode = PLAY_MODE_BY_MEANING[
423  (sonos_shuffle, sonos_repeat)
424  ]
425 
426  @soco_error()
427  def mute_volume(self, mute: bool) -> None:
428  """Mute (true) or unmute (false) media player."""
429  self.socosoco.mute = mute
430 
431  @soco_error()
432  def select_source(self, source: str) -> None:
433  """Select input source."""
434  soco = self.coordinatorcoordinator.soco
435  if source == SOURCE_LINEIN:
436  soco.switch_to_line_in()
437  return
438 
439  if source == SOURCE_TV:
440  soco.switch_to_tv()
441  return
442 
443  self._play_favorite_by_name_play_favorite_by_name(source)
444 
445  def _play_favorite_by_name(self, name: str) -> None:
446  """Play a favorite by name."""
447  fav = [fav for fav in self.speakerspeaker.favorites if fav.title == name]
448 
449  if len(fav) != 1:
451  translation_domain=SONOS_DOMAIN,
452  translation_key="invalid_favorite",
453  translation_placeholders={
454  "name": name,
455  },
456  )
457 
458  src = fav.pop()
459  self._play_favorite_play_favorite(src)
460 
461  def _play_favorite(self, favorite: DidlFavorite) -> None:
462  """Play a favorite."""
463  uri = favorite.reference.get_uri()
464  soco = self.coordinatorcoordinator.soco
465  if soco.music_source_from_uri(uri) in [
466  MUSIC_SRC_RADIO,
467  MUSIC_SRC_LINE_IN,
468  ]:
469  soco.play_uri(uri, title=favorite.title, timeout=LONG_SERVICE_TIMEOUT)
470  else:
471  soco.clear_queue()
472  soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT)
473  soco.play_from_queue(0)
474 
475  @property
476  def source_list(self) -> list[str]:
477  """List of available input sources."""
478  model = self.coordinatorcoordinator.model_name.split()[-1].upper()
479  if model in MODELS_LINEIN_ONLY:
480  return [SOURCE_LINEIN]
481  if model in MODELS_TV_ONLY:
482  return [SOURCE_TV]
483  if model in MODELS_LINEIN_AND_TV:
484  return [SOURCE_LINEIN, SOURCE_TV]
485  return []
486 
487  @soco_error(UPNP_ERRORS_TO_IGNORE)
488  def media_play(self) -> None:
489  """Send play command."""
490  self.coordinatorcoordinator.soco.play()
491 
492  @soco_error(UPNP_ERRORS_TO_IGNORE)
493  def media_stop(self) -> None:
494  """Send stop command."""
495  self.coordinatorcoordinator.soco.stop()
496 
497  @soco_error(UPNP_ERRORS_TO_IGNORE)
498  def media_pause(self) -> None:
499  """Send pause command."""
500  self.coordinatorcoordinator.soco.pause()
501 
502  @soco_error(UPNP_ERRORS_TO_IGNORE)
503  def media_next_track(self) -> None:
504  """Send next track command."""
505  self.coordinatorcoordinator.soco.next()
506 
507  @soco_error(UPNP_ERRORS_TO_IGNORE)
508  def media_previous_track(self) -> None:
509  """Send next track command."""
510  self.coordinatorcoordinator.soco.previous()
511 
512  @soco_error(UPNP_ERRORS_TO_IGNORE)
513  def media_seek(self, position: float) -> None:
514  """Send seek command."""
515  self.coordinatorcoordinator.soco.seek(str(datetime.timedelta(seconds=int(position))))
516 
517  @soco_error()
518  def clear_playlist(self) -> None:
519  """Clear players playlist."""
520  self.coordinatorcoordinator.soco.clear_queue()
521 
522  async def async_play_media(
523  self, media_type: MediaType | str, media_id: str, **kwargs: Any
524  ) -> None:
525  """Send the play_media command to the media player.
526 
527  If media_id is a Plex payload, attempt Plex->Sonos playback.
528 
529  If media_id is an Apple Music, Deezer, Sonos, or Tidal share link,
530  attempt playback using the respective service.
531 
532  If media_type is "playlist", media_id should be a Sonos
533  Playlist name. Otherwise, media_id should be a URI.
534  """
535  is_radio = False
536 
537  if media_source.is_media_source_id(media_id):
538  is_radio = media_id.startswith("media-source://radio_browser/")
539  media_type = MediaType.MUSIC
540  media = await media_source.async_resolve_media(
541  self.hasshass, media_id, self.entity_identity_id
542  )
543  media_id = async_process_play_media_url(self.hasshass, media.url)
544 
545  if kwargs.get(ATTR_MEDIA_ANNOUNCE):
546  volume = kwargs.get("extra", {}).get("volume")
547  _LOGGER.debug("Playing %s using websocket audioclip", media_id)
548  try:
549  assert self.speakerspeaker.websocket
550  response, _ = await self.speakerspeaker.websocket.play_clip(
551  async_process_play_media_url(self.hasshass, media_id),
552  volume=volume,
553  )
554  except SonosWebsocketError as exc:
555  raise HomeAssistantError(
556  f"Error when calling Sonos websocket: {exc}"
557  ) from exc
558  if response.get("success"):
559  return
560  if response.get("type") in ANNOUNCE_NOT_SUPPORTED_ERRORS:
561  # If the speaker does not support announce do not raise and
562  # fall through to_play_media to play the clip directly.
563  _LOGGER.debug(
564  "Speaker %s does not support announce, media_id %s response %s",
565  self.speakerspeaker.zone_name,
566  media_id,
567  response,
568  )
569  else:
570  raise HomeAssistantError(
571  translation_domain=SONOS_DOMAIN,
572  translation_key="announce_media_error",
573  translation_placeholders={
574  "media_id": media_id,
575  "response": response,
576  },
577  )
578 
579  if spotify.is_spotify_media_type(media_type):
580  media_type = spotify.resolve_spotify_media_type(media_type)
581  media_id = spotify.spotify_uri_from_media_browser_url(media_id)
582 
583  await self.hasshass.async_add_executor_job(
584  partial(self._play_media_play_media, media_type, media_id, is_radio, **kwargs)
585  )
586 
587  @soco_error()
589  self, media_type: MediaType | str, media_id: str, is_radio: bool, **kwargs: Any
590  ) -> None:
591  """Wrap sync calls to async_play_media."""
592  _LOGGER.debug("_play_media media_type %s media_id %s", media_type, media_id)
593  enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE)
594 
595  if media_type == "favorite_item_id":
596  favorite = self.speakerspeaker.favorites.lookup_by_item_id(media_id)
597  if favorite is None:
598  raise ValueError(f"Missing favorite for media_id: {media_id}")
599  self._play_favorite_play_favorite(favorite)
600  return
601 
602  soco = self.coordinatorcoordinator.soco
603  if media_id and media_id.startswith(PLEX_URI_SCHEME):
604  plex_plugin = self.speakerspeaker.plex_plugin
605  result = process_plex_payload(
606  self.hasshass, media_type, media_id, supports_playqueues=False
607  )
608  if result.shuffle:
609  self.set_shuffleset_shuffleset_shuffle(True)
610  if enqueue == MediaPlayerEnqueue.ADD:
611  plex_plugin.add_to_queue(result.media, timeout=LONG_SERVICE_TIMEOUT)
612  elif enqueue in (
613  MediaPlayerEnqueue.NEXT,
614  MediaPlayerEnqueue.PLAY,
615  ):
616  pos = (self.mediamedia.queue_position or 0) + 1
617  new_pos = plex_plugin.add_to_queue(
618  result.media, position=pos, timeout=LONG_SERVICE_TIMEOUT
619  )
620  if enqueue == MediaPlayerEnqueue.PLAY:
621  soco.play_from_queue(new_pos - 1)
622  elif enqueue == MediaPlayerEnqueue.REPLACE:
623  soco.clear_queue()
624  plex_plugin.add_to_queue(result.media, timeout=LONG_SERVICE_TIMEOUT)
625  soco.play_from_queue(0)
626  return
627 
628  share_link = self.coordinatorcoordinator.share_link
629  if share_link.is_share_link(media_id):
630  if enqueue == MediaPlayerEnqueue.ADD:
631  share_link.add_share_link_to_queue(
632  media_id, timeout=LONG_SERVICE_TIMEOUT
633  )
634  elif enqueue in (
635  MediaPlayerEnqueue.NEXT,
636  MediaPlayerEnqueue.PLAY,
637  ):
638  pos = (self.mediamedia.queue_position or 0) + 1
639  new_pos = share_link.add_share_link_to_queue(
640  media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT
641  )
642  if enqueue == MediaPlayerEnqueue.PLAY:
643  soco.play_from_queue(new_pos - 1)
644  elif enqueue == MediaPlayerEnqueue.REPLACE:
645  soco.clear_queue()
646  share_link.add_share_link_to_queue(
647  media_id, timeout=LONG_SERVICE_TIMEOUT
648  )
649  soco.play_from_queue(0)
650  elif media_type in {MediaType.MUSIC, MediaType.TRACK}:
651  # If media ID is a relative URL, we serve it from HA.
652  media_id = async_process_play_media_url(self.hasshass, media_id)
653 
654  if enqueue == MediaPlayerEnqueue.ADD:
655  soco.add_uri_to_queue(media_id, timeout=LONG_SERVICE_TIMEOUT)
656  elif enqueue in (
657  MediaPlayerEnqueue.NEXT,
658  MediaPlayerEnqueue.PLAY,
659  ):
660  pos = (self.mediamedia.queue_position or 0) + 1
661  new_pos = soco.add_uri_to_queue(
662  media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT
663  )
664  if enqueue == MediaPlayerEnqueue.PLAY:
665  soco.play_from_queue(new_pos - 1)
666  elif enqueue == MediaPlayerEnqueue.REPLACE:
667  soco.play_uri(media_id, force_radio=is_radio)
668  elif media_type == MediaType.PLAYLIST:
669  if media_id.startswith("S:"):
670  playlist = media_browser.get_media(
671  self.mediamedia.library, media_id, media_type
672  )
673  else:
674  playlists = soco.get_sonos_playlists(complete_result=True)
675  playlist = next((p for p in playlists if p.title == media_id), None)
676  if not playlist:
678  translation_domain=SONOS_DOMAIN,
679  translation_key="invalid_sonos_playlist",
680  translation_placeholders={
681  "name": media_id,
682  },
683  )
684  soco.clear_queue()
685  soco.add_to_queue(playlist, timeout=LONG_SERVICE_TIMEOUT)
686  soco.play_from_queue(0)
687  elif media_type in PLAYABLE_MEDIA_TYPES:
688  item = media_browser.get_media(self.mediamedia.library, media_id, media_type)
689  if not item:
691  translation_domain=SONOS_DOMAIN,
692  translation_key="invalid_media",
693  translation_placeholders={
694  "media_id": media_id,
695  },
696  )
697  self._play_media_queue_play_media_queue(soco, item, enqueue)
698  else:
700  translation_domain=SONOS_DOMAIN,
701  translation_key="invalid_content_type",
702  translation_placeholders={
703  "media_type": media_type,
704  },
705  )
706 
708  self, soco: SoCo, item: MusicServiceItem, enqueue: MediaPlayerEnqueue
709  ):
710  """Manage adding, replacing, playing items onto the sonos queue."""
711  _LOGGER.debug(
712  "_play_media_queue item_id [%s] title [%s] enqueue [%s]",
713  item.item_id,
714  item.title,
715  enqueue,
716  )
717  if enqueue == MediaPlayerEnqueue.REPLACE:
718  soco.clear_queue()
719 
720  if enqueue in (MediaPlayerEnqueue.ADD, MediaPlayerEnqueue.REPLACE):
721  soco.add_to_queue(item, timeout=LONG_SERVICE_TIMEOUT)
722  if enqueue == MediaPlayerEnqueue.REPLACE:
723  soco.play_from_queue(0)
724  else:
725  pos = (self.mediamedia.queue_position or 0) + 1
726  new_pos = soco.add_to_queue(
727  item, position=pos, timeout=LONG_SERVICE_TIMEOUT
728  )
729  if enqueue == MediaPlayerEnqueue.PLAY:
730  soco.play_from_queue(new_pos - 1)
731 
732  @soco_error()
733  def set_sleep_timer(self, sleep_time: int) -> None:
734  """Set the timer on the player."""
735  self.coordinatorcoordinator.soco.set_sleep_timer(sleep_time)
736 
737  @soco_error()
738  def clear_sleep_timer(self) -> None:
739  """Clear the timer on the player."""
740  self.coordinatorcoordinator.soco.set_sleep_timer(None)
741 
742  @soco_error()
744  self,
745  alarm_id: int,
746  time: datetime.datetime | None = None,
747  volume: float | None = None,
748  enabled: bool | None = None,
749  include_linked_zones: bool | None = None,
750  ) -> None:
751  """Set the alarm clock on the player."""
752  alarm: alarms.Alarm | None = None
753  for one_alarm in alarms.get_alarms(self.coordinatorcoordinator.soco):
754  if one_alarm.alarm_id == str(alarm_id):
755  alarm = one_alarm
756  if alarm is None:
757  _LOGGER.warning("Did not find alarm with id %s", alarm_id)
758  return
759  if time is not None:
760  alarm.start_time = time
761  if volume is not None:
762  alarm.volume = int(volume * 100)
763  if enabled is not None:
764  alarm.enabled = enabled
765  if include_linked_zones is not None:
766  alarm.include_linked_zones = include_linked_zones
767  alarm.save()
768 
769  @soco_error()
770  def play_queue(self, queue_position: int = 0) -> None:
771  """Start playing the queue."""
772  self.socosoco.play_from_queue(queue_position)
773 
774  @soco_error()
775  def remove_from_queue(self, queue_position: int = 0) -> None:
776  """Remove item from the queue."""
777  self.coordinatorcoordinator.soco.remove_from_queue(queue_position)
778 
779  @soco_error()
780  def get_queue(self) -> list[dict]:
781  """Get the queue."""
782  queue: list[DidlMusicTrack] = self.coordinatorcoordinator.soco.get_queue(max_items=0)
783  return [
784  {
785  ATTR_MEDIA_TITLE: getattr(track, "title", None),
786  ATTR_MEDIA_ALBUM_NAME: getattr(track, "album", None),
787  ATTR_MEDIA_ARTIST: getattr(track, "creator", None),
788  ATTR_MEDIA_CONTENT_ID: track.get_uri(),
789  }
790  for track in queue
791  ]
792 
793  @property
794  def extra_state_attributes(self) -> dict[str, Any]:
795  """Return entity specific state attributes."""
796  attributes: dict[str, Any] = {}
797 
798  if self.mediamedia.queue_position is not None:
799  attributes[ATTR_QUEUE_POSITION] = self.mediamedia.queue_position
800 
801  if self.mediamedia.queue_size:
802  attributes["queue_size"] = self.mediamedia.queue_size
803 
804  if self.sourcesourcesource:
805  attributes[ATTR_INPUT_SOURCE] = self.sourcesourcesource
806 
807  return attributes
808 
810  self,
811  media_content_type: MediaType | str,
812  media_content_id: str,
813  media_image_id: str | None = None,
814  ) -> tuple[bytes | None, str | None]:
815  """Fetch media browser image to serve via proxy."""
816  if (
817  media_content_type in {MediaType.ALBUM, MediaType.ARTIST}
818  and media_content_id
819  ):
820  item = await self.hasshass.async_add_executor_job(
821  media_browser.get_media,
822  self.mediamedia.library,
823  media_content_id,
824  MEDIA_TYPES_TO_SONOS[media_content_type],
825  )
826  if image_url := getattr(item, "album_art_uri", None):
827  return await self._async_fetch_image_async_fetch_image(image_url)
828 
829  return (None, None)
830 
832  self,
833  media_content_type: MediaType | str | None = None,
834  media_content_id: str | None = None,
835  ) -> BrowseMedia:
836  """Implement the websocket media browsing helper."""
837  return await media_browser.async_browse_media(
838  self.hasshass,
839  self.speakerspeaker,
840  self.mediamedia,
841  self.get_browse_image_urlget_browse_image_url,
842  media_content_id,
843  media_content_type,
844  )
845 
846  async def async_join_players(self, group_members: list[str]) -> None:
847  """Join `group_members` as a player group with the current player."""
848  speakers = []
849  for entity_id in group_members:
850  if speaker := self.hasshass.data[DATA_SONOS].entity_id_mappings.get(entity_id):
851  speakers.append(speaker)
852  else:
853  raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}")
854 
855  await SonosSpeaker.join_multi(self.hasshass, self.speakerspeaker, speakers)
856 
857  async def async_unjoin_player(self) -> None:
858  """Remove this player from any group.
859 
860  Coalesces all calls within UNJOIN_SERVICE_TIMEOUT to allow use of SonosSpeaker.unjoin_multi()
861  which optimizes the order in which speakers are removed from their groups.
862  Removing coordinators last better preserves playqueues on the speakers.
863  """
864  sonos_data = self.hasshass.data[DATA_SONOS]
865  household_id = self.speakerspeaker.household_id
866 
867  async def async_process_unjoin(now: datetime.datetime) -> None:
868  """Process the unjoin with all remove requests within the coalescing period."""
869  unjoin_data = sonos_data.unjoin_data.pop(household_id)
870  _LOGGER.debug(
871  "Processing unjoins for %s", [x.zone_name for x in unjoin_data.speakers]
872  )
873  await SonosSpeaker.unjoin_multi(self.hasshass, unjoin_data.speakers)
874  unjoin_data.event.set()
875 
876  if unjoin_data := sonos_data.unjoin_data.get(household_id):
877  unjoin_data.speakers.append(self.speakerspeaker)
878  else:
879  unjoin_data = sonos_data.unjoin_data[household_id] = UnjoinData(
880  speakers=[self.speakerspeaker]
881  )
882  async_call_later(self.hasshass, UNJOIN_SERVICE_TIMEOUT, async_process_unjoin)
883 
884  _LOGGER.debug("Requesting unjoin for %s", self.speakerspeaker.zone_name)
885  await unjoin_data.event.wait()
str get_browse_image_url(self, str media_content_type, str media_content_id, str|None media_image_id=None)
Definition: __init__.py:1199
tuple[bytes|None, str|None] _async_fetch_image(self, str url)
Definition: __init__.py:1190
def _play_media_queue(self, SoCo soco, MusicServiceItem item, MediaPlayerEnqueue enqueue)
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
None set_alarm(self, int alarm_id, datetime.datetime|None time=None, float|None volume=None, bool|None enabled=None, bool|None include_linked_zones=None)
None _play_media(self, MediaType|str media_type, str media_id, bool is_radio, **Any kwargs)
tuple[bytes|None, str|None] async_get_browse_image(self, MediaType|str media_content_type, str media_content_id, str|None media_image_id=None)
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
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
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
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597