Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support forked_daapd media player."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections import defaultdict
7 import logging
8 from typing import Any
9 
10 from pyforked_daapd import ForkedDaapdAPI
11 from pylibrespot_java import LibrespotJavaAPI
12 
13 from homeassistant.components import media_source
15  ATTR_MEDIA_ANNOUNCE,
16  ATTR_MEDIA_ENQUEUE,
17  BrowseMedia,
18  MediaPlayerEnqueue,
19  MediaPlayerEntity,
20  MediaPlayerEntityFeature,
21  MediaPlayerState,
22  MediaType,
23  async_process_play_media_url,
24 )
26  async_browse_media as spotify_async_browse_media,
27  is_spotify_media_type,
28  resolve_spotify_media_type,
29  spotify_uri_from_media_browser_url,
30 )
31 from homeassistant.config_entries import ConfigEntry
32 from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
33 from homeassistant.core import HomeAssistant, callback
34 from homeassistant.exceptions import PlatformNotReady
35 from homeassistant.helpers.aiohttp_client import async_get_clientsession
37  async_dispatcher_connect,
38  async_dispatcher_send,
39 )
40 from homeassistant.helpers.entity_platform import AddEntitiesCallback
41 from homeassistant.util.dt import utcnow
42 
43 from .browse_media import (
44  convert_to_owntone_uri,
45  get_owntone_content,
46  is_owntone_media_content_id,
47  library,
48 )
49 from .const import (
50  CALLBACK_TIMEOUT,
51  CAN_PLAY_TYPE,
52  CONF_LIBRESPOT_JAVA_PORT,
53  CONF_MAX_PLAYLISTS,
54  CONF_TTS_PAUSE_TIME,
55  CONF_TTS_VOLUME,
56  DEFAULT_TTS_PAUSE_TIME,
57  DEFAULT_TTS_VOLUME,
58  DEFAULT_UNMUTE_VOLUME,
59  DOMAIN,
60  FD_NAME,
61  HASS_DATA_REMOVE_LISTENERS_KEY,
62  HASS_DATA_UPDATER_KEY,
63  KNOWN_PIPES,
64  PIPE_FUNCTION_MAP,
65  SIGNAL_ADD_ZONES,
66  SIGNAL_CONFIG_OPTIONS_UPDATE,
67  SIGNAL_UPDATE_DATABASE,
68  SIGNAL_UPDATE_MASTER,
69  SIGNAL_UPDATE_OUTPUTS,
70  SIGNAL_UPDATE_PLAYER,
71  SIGNAL_UPDATE_QUEUE,
72  SOURCE_NAME_CLEAR,
73  SOURCE_NAME_DEFAULT,
74  STARTUP_DATA,
75  SUPPORTED_FEATURES,
76  SUPPORTED_FEATURES_ZONE,
77  TTS_TIMEOUT,
78 )
79 
80 _LOGGER = logging.getLogger(__name__)
81 
82 WS_NOTIFY_EVENT_TYPES = ["player", "outputs", "volume", "options", "queue", "database"]
83 WEBSOCKET_RECONNECT_TIME = 30 # seconds
84 
85 
87  hass: HomeAssistant,
88  config_entry: ConfigEntry,
89  async_add_entities: AddEntitiesCallback,
90 ) -> None:
91  """Set up forked-daapd from a config entry."""
92  host = config_entry.data[CONF_HOST]
93  port = config_entry.data[CONF_PORT]
94  password = config_entry.data[CONF_PASSWORD]
95  forked_daapd_api = ForkedDaapdAPI(
96  async_get_clientsession(hass), host, port, password
97  )
98  forked_daapd_master = ForkedDaapdMaster(
99  clientsession=async_get_clientsession(hass),
100  api=forked_daapd_api,
101  ip_address=host,
102  api_port=port,
103  api_password=password,
104  config_entry=config_entry,
105  )
106 
107  @callback
108  def async_add_zones(api, outputs):
110  ForkedDaapdZone(api, output, config_entry.entry_id) for output in outputs
111  )
112 
113  remove_add_zones_listener = async_dispatcher_connect(
114  hass, SIGNAL_ADD_ZONES.format(config_entry.entry_id), async_add_zones
115  )
116  remove_entry_listener = config_entry.add_update_listener(update_listener)
117 
118  if not hass.data.get(DOMAIN):
119  hass.data[DOMAIN] = {config_entry.entry_id: {}}
120  hass.data[DOMAIN][config_entry.entry_id] = {
121  HASS_DATA_REMOVE_LISTENERS_KEY: [
122  remove_add_zones_listener,
123  remove_entry_listener,
124  ]
125  }
126  async_add_entities([forked_daapd_master], False)
127  forked_daapd_updater = ForkedDaapdUpdater(
128  hass, forked_daapd_api, config_entry.entry_id
129  )
130  hass.data[DOMAIN][config_entry.entry_id][HASS_DATA_UPDATER_KEY] = (
131  forked_daapd_updater
132  )
133  await forked_daapd_updater.async_init()
134 
135 
136 async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
137  """Handle options update."""
139  hass, SIGNAL_CONFIG_OPTIONS_UPDATE.format(entry.entry_id), entry.options
140  )
141 
142 
144  """Representation of a forked-daapd output."""
145 
146  _attr_should_poll = False
147 
148  def __init__(self, api, output, entry_id):
149  """Initialize the ForkedDaapd Zone."""
150  self._api_api = api
151  self._output_output = output
152  self._output_id_output_id = output["id"]
153  self._last_volume_last_volume = DEFAULT_UNMUTE_VOLUME # used for mute/unmute
154  self._available_available = True
155  self._entry_id_entry_id = entry_id
156 
157  async def async_added_to_hass(self) -> None:
158  """Use lifecycle hooks."""
159  self.async_on_removeasync_on_remove(
161  self.hasshass,
162  SIGNAL_UPDATE_OUTPUTS.format(self._entry_id_entry_id),
163  self._async_update_output_callback_async_update_output_callback,
164  )
165  )
166 
167  @callback
168  def _async_update_output_callback(self, outputs, _event=None):
169  new_output = next(
170  (output for output in outputs if output["id"] == self._output_id_output_id), None
171  )
172  self._available_available = bool(new_output)
173  if self._available_available:
174  self._output_output = new_output
175  self.async_write_ha_stateasync_write_ha_state()
176 
177  @property
178  def unique_id(self):
179  """Return unique ID."""
180  return f"{self._entry_id}-{self._output_id}"
181 
182  async def async_toggle(self) -> None:
183  """Toggle the power on the zone."""
184  if self.statestatestatestatestatestate == MediaPlayerState.OFF:
185  await self.async_turn_onasync_turn_onasync_turn_on()
186  else:
187  await self.async_turn_offasync_turn_offasync_turn_off()
188 
189  @property
190  def available(self) -> bool:
191  """Return whether the zone is available."""
192  return self._available_available
193 
194  async def async_turn_on(self) -> None:
195  """Enable the output."""
196  await self._api_api.change_output(self._output_id_output_id, selected=True)
197 
198  async def async_turn_off(self) -> None:
199  """Disable the output."""
200  await self._api_api.change_output(self._output_id_output_id, selected=False)
201 
202  @property
203  def name(self) -> str:
204  """Return the name of the zone."""
205  return f"{FD_NAME} output ({self._output['name']})"
206 
207  @property
208  def state(self) -> MediaPlayerState:
209  """State of the zone."""
210  if self._output_output["selected"]:
211  return MediaPlayerState.ON
212  return MediaPlayerState.OFF
213 
214  @property
215  def volume_level(self):
216  """Volume level of the media player (0..1)."""
217  return self._output_output["volume"] / 100
218 
219  @property
220  def is_volume_muted(self) -> bool:
221  """Boolean if volume is currently muted."""
222  return self._output_output["volume"] == 0
223 
224  async def async_mute_volume(self, mute: bool) -> None:
225  """Mute the volume."""
226  if mute:
227  if self.volume_levelvolume_levelvolume_levelvolume_level == 0:
228  return
229  self._last_volume_last_volume = self.volume_levelvolume_levelvolume_levelvolume_level # store volume level to restore later
230  target_volume = 0
231  else:
232  target_volume = self._last_volume_last_volume # restore volume level
233  await self.async_set_volume_levelasync_set_volume_levelasync_set_volume_level(volume=target_volume)
234 
235  async def async_set_volume_level(self, volume: float) -> None:
236  """Set volume - input range [0,1]."""
237  await self._api_api.set_volume(volume=volume * 100, output_id=self._output_id_output_id)
238 
239  @property
240  def supported_features(self) -> MediaPlayerEntityFeature:
241  """Flag media player features that are supported."""
242  return SUPPORTED_FEATURES_ZONE
243 
244 
246  """Representation of the main forked-daapd device."""
247 
248  _attr_should_poll = False
249 
250  def __init__(
251  self, clientsession, api, ip_address, api_port, api_password, config_entry
252  ):
253  """Initialize the ForkedDaapd Master Device."""
254  # Leave the api public so the browse media helpers can use it
255  self.apiapi = api
256  self._player_player = STARTUP_DATA[
257  "player"
258  ] # _player, _outputs, and _queue are loaded straight from api
259  self._outputs_outputs = STARTUP_DATA["outputs"]
260  self._queue_queue = STARTUP_DATA["queue"]
261  self._track_info_track_info = defaultdict(
262  str
263  ) # _track info is found by matching _player data with _queue data
264  self._last_outputs_last_outputs = [] # used for device on/off
265  self._last_volume_last_volume = DEFAULT_UNMUTE_VOLUME
266  self._player_last_updated_player_last_updated = None
267  self._pipe_control_api_pipe_control_api = {}
268  self._ip_address_ip_address = (
269  ip_address # need to save this because pipe control is on same ip
270  )
271  self._tts_pause_time_tts_pause_time = DEFAULT_TTS_PAUSE_TIME
272  self._tts_volume_tts_volume = DEFAULT_TTS_VOLUME
273  self._tts_requested_tts_requested = False
274  self._tts_queued_tts_queued = False
275  self._tts_playing_event_tts_playing_event = asyncio.Event()
276  self._on_remove_on_remove_on_remove = None
277  self._available_available = False
278  self._clientsession_clientsession = clientsession
279  self._config_entry_config_entry = config_entry
280  self.update_optionsupdate_options(config_entry.options)
281  self._paused_event_paused_event = asyncio.Event()
282  self._pause_requested_pause_requested = False
283  self._sources_uris_sources_uris = {}
284  self._source_source = SOURCE_NAME_DEFAULT
285  self._max_playlists_max_playlists = None
286 
287  async def async_added_to_hass(self) -> None:
288  """Use lifecycle hooks."""
289  self.async_on_removeasync_on_remove(
291  self.hasshass,
292  SIGNAL_UPDATE_PLAYER.format(self._config_entry_config_entry.entry_id),
293  self._update_player_update_player,
294  )
295  )
296  self.async_on_removeasync_on_remove(
298  self.hasshass,
299  SIGNAL_UPDATE_QUEUE.format(self._config_entry_config_entry.entry_id),
300  self._update_queue_update_queue,
301  )
302  )
303  self.async_on_removeasync_on_remove(
305  self.hasshass,
306  SIGNAL_UPDATE_OUTPUTS.format(self._config_entry_config_entry.entry_id),
307  self._update_outputs_update_outputs,
308  )
309  )
310  self.async_on_removeasync_on_remove(
312  self.hasshass,
313  SIGNAL_UPDATE_MASTER.format(self._config_entry_config_entry.entry_id),
314  self._update_callback_update_callback,
315  )
316  )
317  self.async_on_removeasync_on_remove(
319  self.hasshass,
320  SIGNAL_CONFIG_OPTIONS_UPDATE.format(self._config_entry_config_entry.entry_id),
321  self.update_optionsupdate_options,
322  )
323  )
324  self.async_on_removeasync_on_remove(
326  self.hasshass,
327  SIGNAL_UPDATE_DATABASE.format(self._config_entry_config_entry.entry_id),
328  self._update_database_update_database,
329  )
330  )
331 
332  @callback
333  def _update_callback(self, available):
334  """Call update method."""
335  self._available_available = available
336  self.async_write_ha_stateasync_write_ha_state()
337 
338  @callback
339  def update_options(self, options):
340  """Update forked-daapd server options."""
341  if CONF_LIBRESPOT_JAVA_PORT in options:
342  self._pipe_control_api_pipe_control_api["librespot-java"] = LibrespotJavaAPI(
343  self._clientsession_clientsession, self._ip_address_ip_address, options[CONF_LIBRESPOT_JAVA_PORT]
344  )
345  if CONF_TTS_PAUSE_TIME in options:
346  self._tts_pause_time_tts_pause_time = options[CONF_TTS_PAUSE_TIME]
347  if CONF_TTS_VOLUME in options:
348  self._tts_volume_tts_volume = options[CONF_TTS_VOLUME]
349  if CONF_MAX_PLAYLISTS in options:
350  # sources not updated until next _update_database call
351  self._max_playlists_max_playlists = options[CONF_MAX_PLAYLISTS]
352 
353  @callback
354  def _update_player(self, player, event):
355  self._player_player = player
356  self._player_last_updated_player_last_updated = utcnow()
357  self._update_track_info_update_track_info()
358  if self._tts_queued_tts_queued:
359  self._tts_playing_event_tts_playing_event.set()
360  self._tts_queued_tts_queued = False
361  if self._pause_requested_pause_requested:
362  self._paused_event_paused_event.set()
363  self._pause_requested_pause_requested = False
364  event.set()
365 
366  @callback
367  def _update_queue(self, queue, event):
368  self._queue_queue = queue
369  if self._tts_requested_tts_requested:
370  # Assume the change was due to the request
371  self._tts_requested_tts_requested = False
372  self._tts_queued_tts_queued = True
373 
374  if (
375  self._queue_queue["count"] >= 1
376  and self._queue_queue["items"][0]["data_kind"] == "pipe"
377  and self._queue_queue["items"][0]["title"] in KNOWN_PIPES
378  ): # if we're playing a pipe, set the source automatically so we can forward controls
379  self._source_source = f"{self._queue['items'][0]['title']} (pipe)"
380  self._update_track_info_update_track_info()
381  event.set()
382 
383  @callback
384  def _update_outputs(self, outputs, event=None):
385  if event: # Calling without event is meant for zone, so ignore
386  self._outputs_outputs = outputs
387  event.set()
388 
389  @callback
390  def _update_database(self, pipes, playlists, event):
391  self._sources_uris_sources_uris = {SOURCE_NAME_CLEAR: None, SOURCE_NAME_DEFAULT: None}
392  if pipes:
393  self._sources_uris_sources_uris.update(
394  {
395  f"{pipe['title']} (pipe)": pipe["uri"]
396  for pipe in pipes
397  if pipe["title"] in KNOWN_PIPES
398  }
399  )
400  if playlists:
401  self._sources_uris_sources_uris.update(
402  {
403  f"{playlist['name']} (playlist)": playlist["uri"]
404  for playlist in playlists[: self._max_playlists_max_playlists]
405  }
406  )
407  event.set()
408 
409  def _update_track_info(self): # run during every player or queue update
410  try:
411  self._track_info_track_info = next(
412  track
413  for track in self._queue_queue["items"]
414  if track["id"] == self._player_player["item_id"]
415  )
416  except (StopIteration, TypeError, KeyError):
417  _LOGGER.debug("Could not get track info")
418  self._track_info_track_info = defaultdict(str)
419 
420  @property
421  def unique_id(self):
422  """Return unique ID."""
423  return self._config_entry_config_entry.entry_id
424 
425  @property
426  def available(self) -> bool:
427  """Return whether the master is available."""
428  return self._available_available
429 
430  async def async_turn_on(self) -> None:
431  """Restore the last on outputs state."""
432  # restore state
433  await self.apiapi.set_volume(volume=self._last_volume_last_volume * 100)
434  if self._last_outputs_last_outputs:
435  futures: list[asyncio.Task[int]] = [
436  asyncio.create_task(
437  self.apiapi.change_output(
438  output["id"],
439  selected=output["selected"],
440  volume=output["volume"],
441  )
442  )
443  for output in self._last_outputs_last_outputs
444  ]
445  await asyncio.wait(futures)
446  else: # enable all outputs
447  await self.apiapi.set_enabled_outputs(
448  [output["id"] for output in self._outputs_outputs]
449  )
450 
451  async def async_turn_off(self) -> None:
452  """Pause player and store outputs state."""
453  await self.async_media_pauseasync_media_pauseasync_media_pause()
454  self._last_outputs_last_outputs = self._outputs_outputs
455  if any(output["selected"] for output in self._outputs_outputs):
456  await self.apiapi.set_enabled_outputs([])
457 
458  async def async_toggle(self) -> None:
459  """Toggle the power on the device.
460 
461  Default media player component method counts idle as off.
462  We consider idle to be on but just not playing.
463  """
464  if self.statestatestatestatestatestate == MediaPlayerState.OFF:
465  await self.async_turn_onasync_turn_onasync_turn_on()
466  else:
467  await self.async_turn_offasync_turn_offasync_turn_off()
468 
469  @property
470  def name(self) -> str:
471  """Return the name of the device."""
472  return f"{FD_NAME} server"
473 
474  @property
475  def state(self) -> MediaPlayerState | None:
476  """State of the player."""
477  if self._player_player["state"] == "play":
478  return MediaPlayerState.PLAYING
479  if self._player_player["state"] == "pause":
480  return MediaPlayerState.PAUSED
481  if not any(output["selected"] for output in self._outputs_outputs):
482  return MediaPlayerState.OFF
483  if self._player_player["state"] == "stop": # this should catch all remaining cases
484  return MediaPlayerState.IDLE
485  return None
486 
487  @property
488  def volume_level(self):
489  """Volume level of the media player (0..1)."""
490  return self._player_player["volume"] / 100
491 
492  @property
493  def is_volume_muted(self):
494  """Boolean if volume is currently muted."""
495  return self._player_player["volume"] == 0
496 
497  @property
498  def media_content_id(self):
499  """Content ID of current playing media."""
500  return self._player_player["item_id"]
501 
502  @property
504  """Content type of current playing media."""
505  return self._track_info_track_info["media_kind"]
506 
507  @property
508  def media_duration(self):
509  """Duration of current playing media in seconds."""
510  return self._player_player["item_length_ms"] / 1000
511 
512  @property
513  def media_position(self):
514  """Position of current playing media in seconds."""
515  return self._player_player["item_progress_ms"] / 1000
516 
517  @property
519  """When was the position of the current playing media valid."""
520  return self._player_last_updated_player_last_updated
521 
522  @property
523  def media_title(self):
524  """Title of current playing media."""
525  # Use album field when data_kind is url
526  # https://github.com/ejurgensen/forked-daapd/issues/351
527  if self._track_info_track_info["data_kind"] == "url":
528  return self._track_info_track_info["album"]
529  return self._track_info_track_info["title"]
530 
531  @property
532  def media_artist(self):
533  """Artist of current playing media, music track only."""
534  return self._track_info_track_info["artist"]
535 
536  @property
537  def media_album_name(self):
538  """Album name of current playing media, music track only."""
539  # Use title field when data_kind is url
540  # https://github.com/ejurgensen/forked-daapd/issues/351
541  if self._track_info_track_info["data_kind"] == "url":
542  return self._track_info_track_info["title"]
543  return self._track_info_track_info["album"]
544 
545  @property
547  """Album artist of current playing media, music track only."""
548  return self._track_info_track_info["album_artist"]
549 
550  @property
551  def media_track(self):
552  """Track number of current playing media, music track only."""
553  return self._track_info_track_info["track_number"]
554 
555  @property
556  def shuffle(self):
557  """Boolean if shuffle is enabled."""
558  return self._player_player["shuffle"]
559 
560  @property
561  def supported_features(self) -> MediaPlayerEntityFeature:
562  """Flag media player features that are supported."""
563  return SUPPORTED_FEATURES
564 
565  @property
566  def source(self):
567  """Name of the current input source."""
568  return self._source_source
569 
570  @property
571  def source_list(self):
572  """List of available input sources."""
573  return [*self._sources_uris_sources_uris]
574 
575  async def async_mute_volume(self, mute: bool) -> None:
576  """Mute the volume."""
577  if mute:
578  if self.volume_levelvolume_levelvolume_levelvolume_level == 0:
579  return
580  self._last_volume_last_volume = self.volume_levelvolume_levelvolume_levelvolume_level # store volume level to restore later
581  target_volume = 0
582  else:
583  target_volume = self._last_volume_last_volume # restore volume level
584  await self.apiapi.set_volume(volume=target_volume * 100)
585 
586  async def async_set_volume_level(self, volume: float) -> None:
587  """Set volume - input range [0,1]."""
588  await self.apiapi.set_volume(volume=volume * 100)
589 
590  async def async_media_play(self) -> None:
591  """Start playback."""
592  if self._use_pipe_control_use_pipe_control():
593  await self._pipe_call_pipe_call(self._use_pipe_control_use_pipe_control(), "async_media_play")
594  else:
595  await self.apiapi.start_playback()
596 
597  async def async_media_pause(self) -> None:
598  """Pause playback."""
599  if self._use_pipe_control_use_pipe_control():
600  await self._pipe_call_pipe_call(self._use_pipe_control_use_pipe_control(), "async_media_pause")
601  else:
602  await self.apiapi.pause_playback()
603 
604  async def async_media_stop(self) -> None:
605  """Stop playback."""
606  if self._use_pipe_control_use_pipe_control():
607  await self._pipe_call_pipe_call(self._use_pipe_control_use_pipe_control(), "async_media_stop")
608  else:
609  await self.apiapi.stop_playback()
610 
611  async def async_media_previous_track(self) -> None:
612  """Skip to previous track."""
613  if self._use_pipe_control_use_pipe_control():
614  await self._pipe_call_pipe_call(
615  self._use_pipe_control_use_pipe_control(), "async_media_previous_track"
616  )
617  else:
618  await self.apiapi.previous_track()
619 
620  async def async_media_next_track(self) -> None:
621  """Skip to next track."""
622  if self._use_pipe_control_use_pipe_control():
623  await self._pipe_call_pipe_call(self._use_pipe_control_use_pipe_control(), "async_media_next_track")
624  else:
625  await self.apiapi.next_track()
626 
627  async def async_media_seek(self, position: float) -> None:
628  """Seek to position."""
629  await self.apiapi.seek(position_ms=position * 1000)
630 
631  async def async_clear_playlist(self) -> None:
632  """Clear playlist."""
633  await self.apiapi.clear_queue()
634 
635  async def async_set_shuffle(self, shuffle: bool) -> None:
636  """Enable/disable shuffle mode."""
637  await self.apiapi.shuffle(shuffle)
638 
639  @property
640  def media_image_url(self):
641  """Image url of current playing media."""
642  if url := self._track_info_track_info.get("artwork_url"):
643  url = self.apiapi.full_url(url)
644  return url
645 
646  async def _save_and_set_tts_volumes(self):
647  if self.volume_levelvolume_levelvolume_levelvolume_level: # save master volume
648  self._last_volume_last_volume = self.volume_levelvolume_levelvolume_levelvolume_level
649  self._last_outputs_last_outputs = self._outputs_outputs
650  if self._outputs_outputs:
651  await self.apiapi.set_volume(volume=self._tts_volume_tts_volume * 100)
652  futures = [
653  asyncio.create_task(
654  self.apiapi.change_output(
655  output["id"], selected=True, volume=self._tts_volume_tts_volume * 100
656  )
657  )
658  for output in self._outputs_outputs
659  ]
660  await asyncio.wait(futures)
661 
663  """Send pause and wait for the pause callback to be received."""
664  self._pause_requested_pause_requested = True
665  await self.async_media_pauseasync_media_pauseasync_media_pause()
666  try:
667  async with asyncio.timeout(CALLBACK_TIMEOUT):
668  await self._paused_event_paused_event.wait() # wait for paused
669  except TimeoutError:
670  self._pause_requested_pause_requested = False
671  self._paused_event_paused_event.clear()
672 
673  async def async_play_media(
674  self, media_type: MediaType | str, media_id: str, **kwargs: Any
675  ) -> None:
676  """Play a URI."""
677 
678  # Preprocess media_ids
679  if media_source.is_media_source_id(media_id):
680  media_type = MediaType.MUSIC
681  play_item = await media_source.async_resolve_media(
682  self.hasshass, media_id, self.entity_identity_id
683  )
684  media_id = play_item.url
685  elif is_owntone_media_content_id(media_id):
686  media_id = convert_to_owntone_uri(media_id)
687  elif is_spotify_media_type(media_type):
688  media_type = resolve_spotify_media_type(media_type)
689  media_id = spotify_uri_from_media_browser_url(media_id)
690 
691  if media_type not in CAN_PLAY_TYPE:
692  _LOGGER.warning("Media type '%s' not supported", media_type)
693  return
694 
695  if media_type == MediaType.MUSIC:
696  media_id = async_process_play_media_url(self.hasshass, media_id)
697  elif media_type not in CAN_PLAY_TYPE:
698  _LOGGER.warning("Media type '%s' not supported", media_type)
699  return
700 
701  if kwargs.get(ATTR_MEDIA_ANNOUNCE):
702  await self._async_announce_async_announce(media_id)
703  return
704 
705  # if kwargs[ATTR_MEDIA_ENQUEUE] is None, we assume MediaPlayerEnqueue.REPLACE
706  # if kwargs[ATTR_MEDIA_ENQUEUE] is True, we assume MediaPlayerEnqueue.ADD
707  # kwargs[ATTR_MEDIA_ENQUEUE] is assumed to never be False
708  # See https://github.com/home-assistant/architecture/issues/765
709  enqueue: bool | MediaPlayerEnqueue = kwargs.get(
710  ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE
711  )
712  if enqueue in {True, MediaPlayerEnqueue.ADD, MediaPlayerEnqueue.REPLACE}:
713  await self.apiapi.add_to_queue(
714  uris=media_id,
715  playback="start",
716  clear=enqueue == MediaPlayerEnqueue.REPLACE,
717  )
718  return
719 
720  current_position = next(
721  (
722  item["position"]
723  for item in self._queue_queue["items"]
724  if item["id"] == self._player_player["item_id"]
725  ),
726  0,
727  )
728  if enqueue == MediaPlayerEnqueue.NEXT:
729  await self.apiapi.add_to_queue(
730  uris=media_id,
731  playback="start",
732  position=current_position + 1,
733  )
734  return
735  # enqueue == MediaPlayerEnqueue.PLAY
736  await self.apiapi.add_to_queue(
737  uris=media_id,
738  playback="start",
739  position=current_position,
740  playback_from_position=current_position,
741  )
742 
743  async def _async_announce(self, media_id: str) -> None:
744  """Play a URI."""
745  saved_state = self.statestatestatestatestatestate # save play state
746  saved_mute = self.is_volume_mutedis_volume_mutedis_volume_muted
747  sleep_future = asyncio.create_task(
748  asyncio.sleep(self._tts_pause_time_tts_pause_time)
749  ) # start timing now, but not exact because of fd buffer + tts latency
750  await self._pause_and_wait_for_callback_pause_and_wait_for_callback()
751  await self._save_and_set_tts_volumes_save_and_set_tts_volumes()
752  # save position
753  saved_song_position = self._player_player["item_progress_ms"]
754  saved_queue = self._queue_queue if self._queue_queue["count"] > 0 else None # stash queue
755  if saved_queue:
756  saved_queue_position = next(
757  i
758  for i, item in enumerate(saved_queue["items"])
759  if item["id"] == self._player_player["item_id"]
760  )
761  self._tts_requested_tts_requested = True
762  await sleep_future
763  await self.apiapi.add_to_queue(uris=media_id, playback="start", clear=True)
764  try:
765  async with asyncio.timeout(TTS_TIMEOUT):
766  await self._tts_playing_event_tts_playing_event.wait()
767  # we have started TTS, now wait for completion
768  except TimeoutError:
769  self._tts_requested_tts_requested = False
770  _LOGGER.warning("TTS request timed out")
771  await asyncio.sleep(
772  self._queue_queue["items"][0]["length_ms"]
773  / 1000 # player may not have updated yet so grab length from queue
774  + self._tts_pause_time_tts_pause_time
775  )
776  self._tts_playing_event_tts_playing_event.clear()
777  # TTS done, return to normal
778  await self.async_turn_onasync_turn_onasync_turn_on() # restore outputs and volumes
779  if saved_mute: # mute if we were muted
780  await self.async_mute_volumeasync_mute_volumeasync_mute_volume(True)
781  if self._use_pipe_control_use_pipe_control(): # resume pipe
782  await self.apiapi.add_to_queue(
783  uris=self._sources_uris_sources_uris[self._source_source], clear=True
784  )
785  if saved_state == MediaPlayerState.PLAYING:
786  await self.async_media_playasync_media_playasync_media_play()
787  return
788  if not saved_queue:
789  return
790  # Restore stashed queue
791  await self.apiapi.add_to_queue(
792  uris=",".join(item["uri"] for item in saved_queue["items"]),
793  playback="start",
794  playback_from_position=saved_queue_position,
795  clear=True,
796  )
797  await self.apiapi.seek(position_ms=saved_song_position)
798  if saved_state == MediaPlayerState.PAUSED:
799  await self.async_media_pauseasync_media_pauseasync_media_pause()
800  return
801  if saved_state != MediaPlayerState.PLAYING:
802  await self.async_media_stopasync_media_stopasync_media_stop()
803 
804  async def async_select_source(self, source: str) -> None:
805  """Change source.
806 
807  Source name reflects whether in default mode or pipe mode.
808  Selecting playlists/clear sets the playlists/clears but ends up in default mode.
809  """
810  if source == self._source_source:
811  return
812 
813  if self._use_pipe_control_use_pipe_control(): # if pipe was playing, we need to stop it first
814  await self._pause_and_wait_for_callback_pause_and_wait_for_callback()
815  self._source_source = source
816  if not self._use_pipe_control_use_pipe_control(): # playlist or clear ends up at default
817  self._source_source = SOURCE_NAME_DEFAULT
818  if self._sources_uris_sources_uris.get(source): # load uris for pipes or playlists
819  await self.apiapi.add_to_queue(uris=self._sources_uris_sources_uris[source], clear=True)
820  elif source == SOURCE_NAME_CLEAR: # clear playlist
821  await self.apiapi.clear_queue()
822  self.async_write_ha_stateasync_write_ha_state()
823 
824  def _use_pipe_control(self):
825  """Return which pipe control from KNOWN_PIPES to use."""
826  if self._source_source[-7:] == " (pipe)":
827  return self._source_source[:-7]
828  return ""
829 
830  async def _pipe_call(self, pipe_name, base_function_name) -> None:
831  if pipe := self._pipe_control_api_pipe_control_api.get(pipe_name):
832  await getattr(
833  pipe,
834  PIPE_FUNCTION_MAP[pipe_name][base_function_name],
835  )()
836  return
837  _LOGGER.warning("No pipe control available for %s", pipe_name)
838 
840  self,
841  media_content_type: MediaType | str | None = None,
842  media_content_id: str | None = None,
843  ) -> BrowseMedia:
844  """Implement the websocket media browsing helper."""
845  if media_content_id is None or media_source.is_media_source_id(
846  media_content_id
847  ):
848  ms_result = await media_source.async_browse_media(
849  self.hasshass,
850  media_content_id,
851  content_filter=lambda bm: bm.media_content_type in CAN_PLAY_TYPE,
852  )
853  if media_content_type is not None:
854  return ms_result
855  other_sources: list[BrowseMedia] = (
856  list(ms_result.children) if ms_result.children else []
857  )
858  if "spotify" in self.hasshass.config.components and (
859  media_content_type is None or is_spotify_media_type(media_content_type)
860  ):
861  spotify_result = await spotify_async_browse_media(
862  self.hasshass, media_content_type, media_content_id
863  )
864  if media_content_type is not None:
865  return spotify_result
866  if spotify_result.children:
867  other_sources += spotify_result.children
868 
869  if media_content_id is None or media_content_type is None:
870  # This is the base level, so we combine our library with the other sources
871  return library(other_sources)
872 
873  # media_content_type should only be None if media_content_id is None
874  return await get_owntone_content(self, media_content_id)
875 
877  self,
878  media_content_type: MediaType | str,
879  media_content_id: str,
880  media_image_id: str | None = None,
881  ) -> tuple[bytes | None, str | None]:
882  """Fetch image for media browser."""
883 
884  if media_content_type not in {
885  MediaType.TRACK,
886  MediaType.ALBUM,
887  MediaType.ARTIST,
888  }:
889  return None, None
890  owntone_uri = convert_to_owntone_uri(media_content_id)
891  item_id_str = owntone_uri.rsplit(":", maxsplit=1)[-1]
892  if media_content_type == MediaType.TRACK:
893  result = await self.apiapi.get_track(int(item_id_str))
894  elif media_content_type == MediaType.ALBUM:
895  if result := await self.apiapi.get_albums():
896  result = next(
897  (item for item in result if item["id"] == item_id_str), None
898  )
899  elif result := await self.apiapi.get_artists():
900  result = next((item for item in result if item["id"] == item_id_str), None)
901  if url := result.get("artwork_url"):
902  return await self._async_fetch_image_async_fetch_image(self.apiapi.full_url(url))
903  return None, None
904 
905 
907  """Manage updates for the forked-daapd device."""
908 
909  def __init__(self, hass, api, entry_id):
910  """Initialize."""
911  self.hasshass = hass
912  self._api_api = api
913  self.websocket_handlerwebsocket_handler = None
914  self._all_output_ids_all_output_ids = set()
915  self._entry_id_entry_id = entry_id
916 
917  async def async_init(self):
918  """Perform async portion of class initialization."""
919  if not (server_config := await self._api_api.get_request("config")):
920  raise PlatformNotReady
921  if websocket_port := server_config.get("websocket_port"):
922  self.websocket_handlerwebsocket_handler = asyncio.create_task(
923  self._api_api.start_websocket_handler(
924  websocket_port,
925  WS_NOTIFY_EVENT_TYPES,
926  self._update_update,
927  WEBSOCKET_RECONNECT_TIME,
928  self._disconnected_callback_disconnected_callback,
929  )
930  )
931  else:
932  _LOGGER.error("Invalid websocket port")
933 
934  async def _disconnected_callback(self):
935  """Send update signals when the websocket gets disconnected."""
937  self.hasshass, SIGNAL_UPDATE_MASTER.format(self._entry_id_entry_id), False
938  )
940  self.hasshass, SIGNAL_UPDATE_OUTPUTS.format(self._entry_id_entry_id), []
941  )
942 
943  async def _update(self, update_types):
944  """Private update method."""
945  update_types = set(update_types)
946  update_events = {}
947  _LOGGER.debug("Updating %s", update_types)
948  if (
949  "queue" in update_types
950  ): # update queue, queue before player for async_play_media
951  if queue := await self._api_api.get_request("queue"):
952  update_events["queue"] = asyncio.Event()
954  self.hasshass,
955  SIGNAL_UPDATE_QUEUE.format(self._entry_id_entry_id),
956  queue,
957  update_events["queue"],
958  )
959  # order of below don't matter
960  if not {"outputs", "volume"}.isdisjoint(update_types): # update outputs
961  if outputs := await self._api_api.get_request("outputs"):
962  outputs = outputs["outputs"]
963  update_events["outputs"] = (
964  asyncio.Event()
965  ) # only for master, zones should ignore
967  self.hasshass,
968  SIGNAL_UPDATE_OUTPUTS.format(self._entry_id_entry_id),
969  outputs,
970  update_events["outputs"],
971  )
972  self._add_zones_add_zones(outputs)
973  if not {"database"}.isdisjoint(update_types):
974  pipes, playlists = await asyncio.gather(
975  self._api_api.get_pipes(), self._api_api.get_playlists()
976  )
977  update_events["database"] = asyncio.Event()
979  self.hasshass,
980  SIGNAL_UPDATE_DATABASE.format(self._entry_id_entry_id),
981  pipes,
982  playlists,
983  update_events["database"],
984  )
985  if not {"update", "config"}.isdisjoint(update_types): # not supported
986  _LOGGER.debug("update/config notifications neither requested nor supported")
987  if not {"player", "options", "volume"}.isdisjoint(
988  update_types
989  ): # update player
990  if player := await self._api_api.get_request("player"):
991  update_events["player"] = asyncio.Event()
992  if update_events.get("queue"):
993  await update_events[
994  "queue"
995  ].wait() # make sure queue done before player for async_play_media
997  self.hasshass,
998  SIGNAL_UPDATE_PLAYER.format(self._entry_id_entry_id),
999  player,
1000  update_events["player"],
1001  )
1002  if update_events:
1003  await asyncio.wait(
1004  [asyncio.create_task(event.wait()) for event in update_events.values()]
1005  ) # make sure callbacks done before update
1007  self.hasshass, SIGNAL_UPDATE_MASTER.format(self._entry_id_entry_id), True
1008  )
1009 
1010  def _add_zones(self, outputs):
1011  outputs_to_add = []
1012  for output in outputs:
1013  if output["id"] not in self._all_output_ids_all_output_ids:
1014  self._all_output_ids_all_output_ids.add(output["id"])
1015  outputs_to_add.append(output)
1016  if outputs_to_add:
1018  self.hasshass,
1019  SIGNAL_ADD_ZONES.format(self._entry_id_entry_id),
1020  self._api_api,
1021  outputs_to_add,
1022  )
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_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
def __init__(self, clientsession, api, ip_address, api_port, api_password, config_entry)
tuple[bytes|None, str|None] _async_fetch_image(self, str url)
Definition: __init__.py:1190
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
bool add(self, _T matcher)
Definition: match.py:185
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
BrowseMedia library(Sequence[BrowseMedia]|None other)
bool is_owntone_media_content_id(str media_content_id)
BrowseMedia get_owntone_content(media_player.ForkedDaapdMaster master, str media_content_id)
None update_listener(HomeAssistant hass, ConfigEntry entry)
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: media_player.py:90
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
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
bool is_spotify_media_type(str media_content_type)
Definition: util.py:11
str resolve_spotify_media_type(str media_content_type)
Definition: util.py:16
str spotify_uri_from_media_browser_url(str media_content_id)
Definition: util.py:28
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
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