Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for interfacing to the SqueezeBox API."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from datetime import datetime
7 import json
8 import logging
9 from typing import TYPE_CHECKING, Any
10 
11 from pysqueezebox import Server, async_discover
12 import voluptuous as vol
13 
14 from homeassistant.components import media_source
16  ATTR_MEDIA_ENQUEUE,
17  BrowseError,
18  BrowseMedia,
19  MediaPlayerEnqueue,
20  MediaPlayerEntity,
21  MediaPlayerEntityFeature,
22  MediaPlayerState,
23  MediaType,
24  RepeatMode,
25  async_process_play_media_url,
26 )
27 from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY
28 from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_PORT, Platform
29 from homeassistant.core import HomeAssistant, callback
30 from homeassistant.exceptions import ServiceValidationError
31 from homeassistant.helpers import (
32  config_validation as cv,
33  discovery_flow,
34  entity_platform,
35  entity_registry as er,
36 )
38  CONNECTION_NETWORK_MAC,
39  DeviceInfo,
40  format_mac,
41 )
42 from homeassistant.helpers.dispatcher import async_dispatcher_connect
43 from homeassistant.helpers.entity_platform import AddEntitiesCallback
44 from homeassistant.helpers.start import async_at_start
45 from homeassistant.helpers.update_coordinator import CoordinatorEntity
46 from homeassistant.util.dt import utcnow
47 
48 from .browse_media import (
49  build_item_response,
50  generate_playlist,
51  library_payload,
52  media_source_content_filter,
53 )
54 from .const import (
55  DISCOVERY_TASK,
56  DOMAIN,
57  KNOWN_PLAYERS,
58  KNOWN_SERVERS,
59  SIGNAL_PLAYER_DISCOVERED,
60  SQUEEZEBOX_SOURCE_STRINGS,
61 )
62 from .coordinator import SqueezeBoxPlayerUpdateCoordinator
63 
64 if TYPE_CHECKING:
65  from . import SqueezeboxConfigEntry
66 
67 SERVICE_CALL_METHOD = "call_method"
68 SERVICE_CALL_QUERY = "call_query"
69 
70 ATTR_QUERY_RESULT = "query_result"
71 
72 _LOGGER = logging.getLogger(__name__)
73 
74 
75 ATTR_PARAMETERS = "parameters"
76 ATTR_OTHER_PLAYER = "other_player"
77 
78 ATTR_TO_PROPERTY = [
79  ATTR_QUERY_RESULT,
80 ]
81 
82 SQUEEZEBOX_MODE = {
83  "pause": MediaPlayerState.PAUSED,
84  "play": MediaPlayerState.PLAYING,
85  "stop": MediaPlayerState.IDLE,
86 }
87 
88 
89 async def start_server_discovery(hass: HomeAssistant) -> None:
90  """Start a server discovery task."""
91 
92  def _discovered_server(server: Server) -> None:
93  discovery_flow.async_create_flow(
94  hass,
95  DOMAIN,
96  context={"source": SOURCE_INTEGRATION_DISCOVERY},
97  data={
98  CONF_HOST: server.host,
99  CONF_PORT: int(server.port),
100  "uuid": server.uuid,
101  },
102  )
103 
104  hass.data.setdefault(DOMAIN, {})
105  if DISCOVERY_TASK not in hass.data[DOMAIN]:
106  _LOGGER.debug("Adding server discovery task for squeezebox")
107  hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_background_task(
108  async_discover(_discovered_server),
109  name="squeezebox server discovery",
110  )
111 
112 
114  hass: HomeAssistant,
115  entry: SqueezeboxConfigEntry,
116  async_add_entities: AddEntitiesCallback,
117 ) -> None:
118  """Set up the Squeezebox media_player platform from a server config entry."""
119 
120  # Add media player entities when discovered
121  async def _player_discovered(player: SqueezeBoxPlayerUpdateCoordinator) -> None:
122  _LOGGER.debug("Setting up media_player entity for player %s", player)
124 
125  entry.async_on_unload(
126  async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered)
127  )
128 
129  # Register entity services
130  platform = entity_platform.async_get_current_platform()
131  platform.async_register_entity_service(
132  SERVICE_CALL_METHOD,
133  {
134  vol.Required(ATTR_COMMAND): cv.string,
135  vol.Optional(ATTR_PARAMETERS): vol.All(
136  cv.ensure_list, vol.Length(min=1), [cv.string]
137  ),
138  },
139  "async_call_method",
140  )
141  platform.async_register_entity_service(
142  SERVICE_CALL_QUERY,
143  {
144  vol.Required(ATTR_COMMAND): cv.string,
145  vol.Optional(ATTR_PARAMETERS): vol.All(
146  cv.ensure_list, vol.Length(min=1), [cv.string]
147  ),
148  },
149  "async_call_query",
150  )
151 
152  # Start server discovery task if not already running
153  entry.async_on_unload(async_at_start(hass, start_server_discovery))
154 
155 
157  CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator], MediaPlayerEntity
158 ):
159  """Representation of the media player features of a SqueezeBox device.
160 
161  Wraps a pysqueezebox.Player() object.
162  """
163 
164  _attr_supported_features = (
165  MediaPlayerEntityFeature.BROWSE_MEDIA
166  | MediaPlayerEntityFeature.PAUSE
167  | MediaPlayerEntityFeature.VOLUME_SET
168  | MediaPlayerEntityFeature.VOLUME_MUTE
169  | MediaPlayerEntityFeature.PREVIOUS_TRACK
170  | MediaPlayerEntityFeature.NEXT_TRACK
171  | MediaPlayerEntityFeature.SEEK
172  | MediaPlayerEntityFeature.TURN_ON
173  | MediaPlayerEntityFeature.TURN_OFF
174  | MediaPlayerEntityFeature.PLAY_MEDIA
175  | MediaPlayerEntityFeature.PLAY
176  | MediaPlayerEntityFeature.REPEAT_SET
177  | MediaPlayerEntityFeature.SHUFFLE_SET
178  | MediaPlayerEntityFeature.CLEAR_PLAYLIST
179  | MediaPlayerEntityFeature.STOP
180  | MediaPlayerEntityFeature.GROUPING
181  | MediaPlayerEntityFeature.MEDIA_ENQUEUE
182  )
183  _attr_has_entity_name = True
184  _attr_name = None
185  _last_update: datetime | None = None
186 
187  def __init__(
188  self,
189  coordinator: SqueezeBoxPlayerUpdateCoordinator,
190  ) -> None:
191  """Initialize the SqueezeBox device."""
192  super().__init__(coordinator)
193  player = coordinator.player
194  self._player_player = player
195  self._query_result_query_result: bool | dict = {}
196  self._remove_dispatcher_remove_dispatcher: Callable | None = None
197  self._previous_media_position_previous_media_position = 0
198  self._attr_unique_id_attr_unique_id = format_mac(player.player_id)
199  _manufacturer = None
200  if player.model == "SqueezeLite" or "SqueezePlay" in player.model:
201  _manufacturer = "Ralph Irving"
202  elif (
203  "Squeezebox" in player.model
204  or "Transporter" in player.model
205  or "Slim" in player.model
206  ):
207  _manufacturer = "Logitech"
208 
209  self._attr_device_info_attr_device_info = DeviceInfo(
210  identifiers={(DOMAIN, self._attr_unique_id_attr_unique_id)},
211  name=player.name,
212  connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id_attr_unique_id)},
213  via_device=(DOMAIN, coordinator.server_uuid),
214  model=player.model,
215  manufacturer=_manufacturer,
216  )
217 
218  @callback
219  def _handle_coordinator_update(self) -> None:
220  """Handle updated data from the coordinator."""
221  if self._previous_media_position_previous_media_position != self.media_positionmedia_positionmedia_position:
222  self._previous_media_position_previous_media_position = self.media_positionmedia_positionmedia_position
223  self._last_update_last_update = utcnow()
224  self.async_write_ha_stateasync_write_ha_state()
225 
226  @property
227  def available(self) -> bool:
228  """Return True if entity is available."""
229  return self.coordinator.available and super().available
230 
231  @property
232  def extra_state_attributes(self) -> dict[str, Any]:
233  """Return device-specific attributes."""
234  return {
235  attr: getattr(self, attr)
236  for attr in ATTR_TO_PROPERTY
237  if getattr(self, attr) is not None
238  }
239 
240  @property
241  def state(self) -> MediaPlayerState | None:
242  """Return the state of the device."""
243  if not self._player_player.power:
244  return MediaPlayerState.OFF
245  if self._player_player.mode and self._player_player.mode in SQUEEZEBOX_MODE:
246  return SQUEEZEBOX_MODE[self._player_player.mode]
247  _LOGGER.error(
248  "Received unknown mode %s from player %s", self._player_player.mode, self.namenamename
249  )
250  return None
251 
252  async def async_will_remove_from_hass(self) -> None:
253  """Remove from list of known players when removed from hass."""
254  known_servers = self.hasshasshass.data[DOMAIN][KNOWN_SERVERS]
255  known_players = known_servers[self.coordinator.server_uuid][KNOWN_PLAYERS]
256  known_players.remove(self.coordinator.player.player_id)
257 
258  @property
259  def volume_level(self) -> float | None:
260  """Volume level of the media player (0..1)."""
261  if self._player_player.volume:
262  return int(float(self._player_player.volume)) / 100.0
263 
264  return None
265 
266  @property
267  def is_volume_muted(self) -> bool:
268  """Return true if volume is muted."""
269  return bool(self._player_player.muting)
270 
271  @property
272  def media_content_id(self) -> str | None:
273  """Content ID of current playing media."""
274  if not self._player_player.playlist:
275  return None
276  if len(self._player_player.playlist) > 1:
277  urls = [{"url": track["url"]} for track in self._player_player.playlist]
278  return json.dumps({"index": self._player_player.current_index, "urls": urls})
279  return str(self._player_player.url)
280 
281  @property
282  def media_content_type(self) -> MediaType | None:
283  """Content type of current playing media."""
284  if not self._player_player.playlist:
285  return None
286  if len(self._player_player.playlist) > 1:
287  return MediaType.PLAYLIST
288  return MediaType.MUSIC
289 
290  @property
291  def media_duration(self) -> int:
292  """Duration of current playing media in seconds."""
293  return int(self._player_player.duration) if self._player_player.duration else 0
294 
295  @property
296  def media_position(self) -> int:
297  """Position of current playing media in seconds."""
298  return int(self._player_player.time) if self._player_player.time else 0
299 
300  @property
301  def media_position_updated_at(self) -> datetime | None:
302  """Last time status was updated."""
303  return self._last_update_last_update
304 
305  @property
306  def media_image_url(self) -> str | None:
307  """Image url of current playing media."""
308  return str(self._player_player.image_url) if self._player_player.image_url else None
309 
310  @property
311  def media_title(self) -> str | None:
312  """Title of current playing media."""
313  return str(self._player_player.title)
314 
315  @property
316  def media_channel(self) -> str | None:
317  """Channel (e.g. webradio name) of current playing media."""
318  return str(self._player_player.remote_title)
319 
320  @property
321  def media_artist(self) -> str | None:
322  """Artist of current playing media."""
323  return str(self._player_player.artist)
324 
325  @property
326  def media_album_name(self) -> str | None:
327  """Album of current playing media."""
328  return str(self._player_player.album)
329 
330  @property
331  def repeat(self) -> RepeatMode:
332  """Repeat setting."""
333  if self._player_player.repeat == "song":
334  return RepeatMode.ONE
335  if self._player_player.repeat == "playlist":
336  return RepeatMode.ALL
337  return RepeatMode.OFF
338 
339  @property
340  def shuffle(self) -> bool:
341  """Boolean if shuffle is enabled."""
342  # Squeezebox has a third shuffle mode (album) not recognized by Home Assistant
343  return bool(self._player_player.shuffle == "song")
344 
345  @property
346  def group_members(self) -> list[str]:
347  """List players we are synced with."""
348  ent_reg = er.async_get(self.hasshasshass)
349  return [
350  entity_id
351  for player in self._player_player.sync_group
352  if (
353  entity_id := ent_reg.async_get_entity_id(
354  Platform.MEDIA_PLAYER, DOMAIN, player
355  )
356  )
357  ]
358 
359  @property
360  def query_result(self) -> dict | bool:
361  """Return the result from the call_query service."""
362  return self._query_result_query_result
363 
364  async def async_turn_off(self) -> None:
365  """Turn off media player."""
366  await self._player_player.async_set_power(False)
367  await self.coordinator.async_refresh()
368 
369  async def async_volume_up(self) -> None:
370  """Volume up media player."""
371  await self._player_player.async_set_volume("+5")
372  await self.coordinator.async_refresh()
373 
374  async def async_volume_down(self) -> None:
375  """Volume down media player."""
376  await self._player_player.async_set_volume("-5")
377  await self.coordinator.async_refresh()
378 
379  async def async_set_volume_level(self, volume: float) -> None:
380  """Set volume level, range 0..1."""
381  volume_percent = str(int(volume * 100))
382  await self._player_player.async_set_volume(volume_percent)
383  await self.coordinator.async_refresh()
384 
385  async def async_mute_volume(self, mute: bool) -> None:
386  """Mute (true) or unmute (false) media player."""
387  await self._player_player.async_set_muting(mute)
388  await self.coordinator.async_refresh()
389 
390  async def async_media_stop(self) -> None:
391  """Send stop command to media player."""
392  await self._player_player.async_stop()
393  await self.coordinator.async_refresh()
394 
395  async def async_media_play_pause(self) -> None:
396  """Send pause command to media player."""
397  await self._player_player.async_toggle_pause()
398  await self.coordinator.async_refresh()
399 
400  async def async_media_play(self) -> None:
401  """Send play command to media player."""
402  await self._player_player.async_play()
403  await self.coordinator.async_refresh()
404 
405  async def async_media_pause(self) -> None:
406  """Send pause command to media player."""
407  await self._player_player.async_pause()
408  await self.coordinator.async_refresh()
409 
410  async def async_media_next_track(self) -> None:
411  """Send next track command."""
412  await self._player_player.async_index("+1")
413  await self.coordinator.async_refresh()
414 
415  async def async_media_previous_track(self) -> None:
416  """Send next track command."""
417  await self._player_player.async_index("-1")
418  await self.coordinator.async_refresh()
419 
420  async def async_media_seek(self, position: float) -> None:
421  """Send seek command."""
422  await self._player_player.async_time(position)
423  await self.coordinator.async_refresh()
424 
425  async def async_turn_on(self) -> None:
426  """Turn the media player on."""
427  await self._player_player.async_set_power(True)
428  await self.coordinator.async_refresh()
429 
430  async def async_play_media(
431  self, media_type: MediaType | str, media_id: str, **kwargs: Any
432  ) -> None:
433  """Send the play_media command to the media player."""
434  index = None
435 
436  enqueue: MediaPlayerEnqueue | None = kwargs.get(ATTR_MEDIA_ENQUEUE)
437 
438  if enqueue == MediaPlayerEnqueue.ADD:
439  cmd = "add"
440  elif enqueue == MediaPlayerEnqueue.NEXT:
441  cmd = "insert"
442  elif enqueue == MediaPlayerEnqueue.PLAY:
443  cmd = "play_now"
444  else:
445  cmd = "play"
446 
447  if media_source.is_media_source_id(media_id):
448  media_type = MediaType.MUSIC
449  play_item = await media_source.async_resolve_media(
450  self.hasshasshass, media_id, self.entity_identity_id
451  )
452  media_id = play_item.url
453 
454  if media_type in MediaType.MUSIC:
455  if not media_id.startswith(SQUEEZEBOX_SOURCE_STRINGS):
456  # do not process special squeezebox "source" media ids
457  media_id = async_process_play_media_url(self.hasshasshass, media_id)
458 
459  await self._player_player.async_load_url(media_id, cmd)
460  return
461 
462  if media_type == MediaType.PLAYLIST:
463  try:
464  # a saved playlist by number
465  payload = {
466  "search_id": media_id,
467  "search_type": MediaType.PLAYLIST,
468  }
469  playlist = await generate_playlist(self._player_player, payload)
470  except BrowseError:
471  # a list of urls
472  content = json.loads(media_id)
473  playlist = content["urls"]
474  index = content["index"]
475  else:
476  payload = {
477  "search_id": media_id,
478  "search_type": media_type,
479  }
480  playlist = await generate_playlist(self._player_player, payload)
481 
482  _LOGGER.debug("Generated playlist: %s", playlist)
483 
484  await self._player_player.async_load_playlist(playlist, cmd)
485  if index is not None:
486  await self._player_player.async_index(index)
487  await self.coordinator.async_refresh()
488 
489  async def async_set_repeat(self, repeat: RepeatMode) -> None:
490  """Set the repeat mode."""
491  if repeat == RepeatMode.ALL:
492  repeat_mode = "playlist"
493  elif repeat == RepeatMode.ONE:
494  repeat_mode = "song"
495  else:
496  repeat_mode = "none"
497 
498  await self._player_player.async_set_repeat(repeat_mode)
499  await self.coordinator.async_refresh()
500 
501  async def async_set_shuffle(self, shuffle: bool) -> None:
502  """Enable/disable shuffle mode."""
503  shuffle_mode = "song" if shuffle else "none"
504  await self._player_player.async_set_shuffle(shuffle_mode)
505  await self.coordinator.async_refresh()
506 
507  async def async_clear_playlist(self) -> None:
508  """Send the media player the command for clear playlist."""
509  await self._player_player.async_clear_playlist()
510  await self.coordinator.async_refresh()
511 
512  async def async_call_method(
513  self, command: str, parameters: list[str] | None = None
514  ) -> None:
515  """Call Squeezebox JSON/RPC method.
516 
517  Additional parameters are added to the command to form the list of
518  positional parameters (p0, p1..., pN) passed to JSON/RPC server.
519  """
520  all_params = [command]
521  if parameters:
522  all_params.extend(parameters)
523  await self._player_player.async_query(*all_params)
524 
525  async def async_call_query(
526  self, command: str, parameters: list[str] | None = None
527  ) -> None:
528  """Call Squeezebox JSON/RPC method where we care about the result.
529 
530  Additional parameters are added to the command to form the list of
531  positional parameters (p0, p1..., pN) passed to JSON/RPC server.
532  """
533  all_params = [command]
534  if parameters:
535  all_params.extend(parameters)
536  self._query_result_query_result = await self._player_player.async_query(*all_params)
537  _LOGGER.debug("call_query got result %s", self._query_result_query_result)
538  self.async_write_ha_stateasync_write_ha_state()
539 
540  async def async_join_players(self, group_members: list[str]) -> None:
541  """Add other Squeezebox players to this player's sync group.
542 
543  If the other player is a member of a sync group, it will leave the current sync group
544  without asking.
545  """
546  ent_reg = er.async_get(self.hasshasshass)
547  for other_player_entity_id in group_members:
548  other_player = ent_reg.async_get(other_player_entity_id)
549  if other_player is None:
551  f"Could not find player with entity_id {other_player_entity_id}"
552  )
553  if other_player_id := other_player.unique_id:
554  await self._player_player.async_sync(other_player_id)
555  else:
557  f"Could not join unknown player {other_player_entity_id}"
558  )
559 
560  async def async_unjoin_player(self) -> None:
561  """Unsync this Squeezebox player."""
562  await self._player_player.async_unsync()
563  await self.coordinator.async_refresh()
564 
566  self,
567  media_content_type: MediaType | str | None = None,
568  media_content_id: str | None = None,
569  ) -> BrowseMedia:
570  """Implement the websocket media browsing helper."""
571  _LOGGER.debug(
572  "Reached async_browse_media with content_type %s and content_id %s",
573  media_content_type,
574  media_content_id,
575  )
576 
577  if media_content_type in [None, "library"]:
578  return await library_payload(self.hasshasshass, self._player_player)
579 
580  if media_content_id and media_source.is_media_source_id(media_content_id):
581  return await media_source.async_browse_media(
582  self.hasshasshass, media_content_id, content_filter=media_source_content_filter
583  )
584 
585  payload = {
586  "search_type": media_content_type,
587  "search_id": media_content_id,
588  }
589 
590  return await build_item_response(self, self._player_player, payload)
591 
593  self,
594  media_content_type: MediaType | str,
595  media_content_id: str,
596  media_image_id: str | None = None,
597  ) -> tuple[bytes | None, str | None]:
598  """Get album art from Squeezebox server."""
599  if media_image_id:
600  image_url = self._player_player.generate_image_url_from_track_id(media_image_id)
601  result = await self._async_fetch_image_async_fetch_image(image_url)
602  if result == (None, None):
603  _LOGGER.debug("Error retrieving proxied album art from %s", image_url)
604  return result
605 
606  return (None, None)
tuple[bytes|None, str|None] _async_fetch_image(self, str url)
Definition: __init__.py:1190
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
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_call_method(self, str command, list[str]|None parameters=None)
None __init__(self, SqueezeBoxPlayerUpdateCoordinator coordinator)
None async_call_query(self, str command, list[str]|None parameters=None)
str|UndefinedType|None name(self)
Definition: entity.py:738
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
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
None async_stop(HomeAssistant hass)
Definition: discovery.py:694
None async_discover(DiscoveryInfo discovery_info)
Definition: sensor.py:217
list|None generate_playlist(Player player, dict[str, str] payload)
None async_setup_entry(HomeAssistant hass, SqueezeboxConfigEntry entry, AddEntitiesCallback async_add_entities)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
CALLBACK_TYPE async_at_start(HomeAssistant hass, Callable[[HomeAssistant], Coroutine[Any, Any, None]|None] at_start_cb)
Definition: start.py:61