1 """Support forked_daapd media player."""
3 from __future__
import annotations
6 from collections
import defaultdict
10 from pyforked_daapd
import ForkedDaapdAPI
11 from pylibrespot_java
import LibrespotJavaAPI
20 MediaPlayerEntityFeature,
23 async_process_play_media_url,
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,
37 async_dispatcher_connect,
38 async_dispatcher_send,
43 from .browse_media
import (
44 convert_to_owntone_uri,
46 is_owntone_media_content_id,
52 CONF_LIBRESPOT_JAVA_PORT,
56 DEFAULT_TTS_PAUSE_TIME,
58 DEFAULT_UNMUTE_VOLUME,
61 HASS_DATA_REMOVE_LISTENERS_KEY,
62 HASS_DATA_UPDATER_KEY,
66 SIGNAL_CONFIG_OPTIONS_UPDATE,
67 SIGNAL_UPDATE_DATABASE,
69 SIGNAL_UPDATE_OUTPUTS,
76 SUPPORTED_FEATURES_ZONE,
80 _LOGGER = logging.getLogger(__name__)
82 WS_NOTIFY_EVENT_TYPES = [
"player",
"outputs",
"volume",
"options",
"queue",
"database"]
83 WEBSOCKET_RECONNECT_TIME = 30
88 config_entry: ConfigEntry,
89 async_add_entities: AddEntitiesCallback,
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(
100 api=forked_daapd_api,
103 api_password=password,
104 config_entry=config_entry,
108 def async_add_zones(api, outputs):
110 ForkedDaapdZone(api, output, config_entry.entry_id)
for output
in outputs
114 hass, SIGNAL_ADD_ZONES.format(config_entry.entry_id), async_add_zones
116 remove_entry_listener = config_entry.add_update_listener(update_listener)
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,
128 hass, forked_daapd_api, config_entry.entry_id
130 hass.data[DOMAIN][config_entry.entry_id][HASS_DATA_UPDATER_KEY] = (
133 await forked_daapd_updater.async_init()
137 """Handle options update."""
139 hass, SIGNAL_CONFIG_OPTIONS_UPDATE.format(entry.entry_id), entry.options
144 """Representation of a forked-daapd output."""
146 _attr_should_poll =
False
149 """Initialize the ForkedDaapd Zone."""
158 """Use lifecycle hooks."""
162 SIGNAL_UPDATE_OUTPUTS.format(self.
_entry_id_entry_id),
170 (output
for output
in outputs
if output[
"id"] == self.
_output_id_output_id),
None
174 self.
_output_output = new_output
179 """Return unique ID."""
180 return f
"{self._entry_id}-{self._output_id}"
183 """Toggle the power on the zone."""
191 """Return whether the zone is available."""
195 """Enable the output."""
196 await self.
_api_api.change_output(self.
_output_id_output_id, selected=
True)
199 """Disable the output."""
200 await self.
_api_api.change_output(self.
_output_id_output_id, selected=
False)
204 """Return the name of the zone."""
205 return f
"{FD_NAME} output ({self._output['name']})"
208 def state(self) -> MediaPlayerState:
209 """State of the zone."""
210 if self.
_output_output[
"selected"]:
211 return MediaPlayerState.ON
212 return MediaPlayerState.OFF
216 """Volume level of the media player (0..1)."""
217 return self.
_output_output[
"volume"] / 100
221 """Boolean if volume is currently muted."""
222 return self.
_output_output[
"volume"] == 0
225 """Mute the volume."""
236 """Set volume - input range [0,1]."""
237 await self.
_api_api.set_volume(volume=volume * 100, output_id=self.
_output_id_output_id)
241 """Flag media player features that are supported."""
242 return SUPPORTED_FEATURES_ZONE
246 """Representation of the main forked-daapd device."""
248 _attr_should_poll =
False
251 self, clientsession, api, ip_address, api_port, api_password, config_entry
253 """Initialize the ForkedDaapd Master Device."""
260 self.
_queue_queue = STARTUP_DATA[
"queue"]
288 """Use lifecycle hooks."""
292 SIGNAL_UPDATE_PLAYER.format(self.
_config_entry_config_entry.entry_id),
299 SIGNAL_UPDATE_QUEUE.format(self.
_config_entry_config_entry.entry_id),
306 SIGNAL_UPDATE_OUTPUTS.format(self.
_config_entry_config_entry.entry_id),
313 SIGNAL_UPDATE_MASTER.format(self.
_config_entry_config_entry.entry_id),
320 SIGNAL_CONFIG_OPTIONS_UPDATE.format(self.
_config_entry_config_entry.entry_id),
327 SIGNAL_UPDATE_DATABASE.format(self.
_config_entry_config_entry.entry_id),
334 """Call update method."""
340 """Update forked-daapd server options."""
341 if CONF_LIBRESPOT_JAVA_PORT
in options:
345 if CONF_TTS_PAUSE_TIME
in options:
347 if CONF_TTS_VOLUME
in options:
348 self.
_tts_volume_tts_volume = options[CONF_TTS_VOLUME]
349 if CONF_MAX_PLAYLISTS
in options:
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
379 self.
_source_source = f
"{self._queue['items'][0]['title']} (pipe)"
391 self.
_sources_uris_sources_uris = {SOURCE_NAME_CLEAR:
None, SOURCE_NAME_DEFAULT:
None}
395 f
"{pipe['title']} (pipe)": pipe[
"uri"]
397 if pipe[
"title"]
in KNOWN_PIPES
403 f
"{playlist['name']} (playlist)": playlist[
"uri"]
413 for track
in self.
_queue_queue[
"items"]
414 if track[
"id"] == self.
_player_player[
"item_id"]
416 except (StopIteration, TypeError, KeyError):
417 _LOGGER.debug(
"Could not get track info")
422 """Return unique ID."""
427 """Return whether the master is available."""
431 """Restore the last on outputs state."""
433 await self.
apiapi.set_volume(volume=self.
_last_volume_last_volume * 100)
435 futures: list[asyncio.Task[int]] = [
437 self.
apiapi.change_output(
439 selected=output[
"selected"],
440 volume=output[
"volume"],
445 await asyncio.wait(futures)
447 await self.
apiapi.set_enabled_outputs(
448 [output[
"id"]
for output
in self.
_outputs_outputs]
452 """Pause player and store outputs state."""
455 if any(output[
"selected"]
for output
in self.
_outputs_outputs):
456 await self.
apiapi.set_enabled_outputs([])
459 """Toggle the power on the device.
461 Default media player component method counts idle as off.
462 We consider idle to be on but just not playing.
471 """Return the name of the device."""
472 return f
"{FD_NAME} server"
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":
484 return MediaPlayerState.IDLE
489 """Volume level of the media player (0..1)."""
490 return self.
_player_player[
"volume"] / 100
494 """Boolean if volume is currently muted."""
495 return self.
_player_player[
"volume"] == 0
499 """Content ID of current playing media."""
500 return self.
_player_player[
"item_id"]
504 """Content type of current playing media."""
509 """Duration of current playing media in seconds."""
510 return self.
_player_player[
"item_length_ms"] / 1000
514 """Position of current playing media in seconds."""
515 return self.
_player_player[
"item_progress_ms"] / 1000
519 """When was the position of the current playing media valid."""
524 """Title of current playing media."""
527 if self.
_track_info_track_info[
"data_kind"] ==
"url":
533 """Artist of current playing media, music track only."""
538 """Album name of current playing media, music track only."""
541 if self.
_track_info_track_info[
"data_kind"] ==
"url":
547 """Album artist of current playing media, music track only."""
552 """Track number of current playing media, music track only."""
557 """Boolean if shuffle is enabled."""
558 return self.
_player_player[
"shuffle"]
562 """Flag media player features that are supported."""
563 return SUPPORTED_FEATURES
567 """Name of the current input source."""
572 """List of available input sources."""
576 """Mute the volume."""
584 await self.
apiapi.set_volume(volume=target_volume * 100)
587 """Set volume - input range [0,1]."""
588 await self.
apiapi.set_volume(volume=volume * 100)
591 """Start playback."""
595 await self.
apiapi.start_playback()
598 """Pause playback."""
602 await self.
apiapi.pause_playback()
609 await self.
apiapi.stop_playback()
612 """Skip to previous track."""
618 await self.
apiapi.previous_track()
621 """Skip to next track."""
625 await self.
apiapi.next_track()
628 """Seek to position."""
629 await self.
apiapi.seek(position_ms=position * 1000)
632 """Clear playlist."""
633 await self.
apiapi.clear_queue()
636 """Enable/disable shuffle mode."""
641 """Image url of current playing media."""
643 url = self.
apiapi.full_url(url)
651 await self.
apiapi.set_volume(volume=self.
_tts_volume_tts_volume * 100)
654 self.
apiapi.change_output(
655 output[
"id"], selected=
True, volume=self.
_tts_volume_tts_volume * 100
660 await asyncio.wait(futures)
663 """Send pause and wait for the pause callback to be received."""
667 async
with asyncio.timeout(CALLBACK_TIMEOUT):
674 self, media_type: MediaType | str, media_id: str, **kwargs: Any
679 if media_source.is_media_source_id(media_id):
680 media_type = MediaType.MUSIC
681 play_item = await media_source.async_resolve_media(
684 media_id = play_item.url
691 if media_type
not in CAN_PLAY_TYPE:
692 _LOGGER.warning(
"Media type '%s' not supported", media_type)
695 if media_type == MediaType.MUSIC:
697 elif media_type
not in CAN_PLAY_TYPE:
698 _LOGGER.warning(
"Media type '%s' not supported", media_type)
701 if kwargs.get(ATTR_MEDIA_ANNOUNCE):
709 enqueue: bool | MediaPlayerEnqueue = kwargs.get(
710 ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE
712 if enqueue
in {
True, MediaPlayerEnqueue.ADD, MediaPlayerEnqueue.REPLACE}:
713 await self.
apiapi.add_to_queue(
716 clear=enqueue == MediaPlayerEnqueue.REPLACE,
720 current_position = next(
723 for item
in self.
_queue_queue[
"items"]
724 if item[
"id"] == self.
_player_player[
"item_id"]
728 if enqueue == MediaPlayerEnqueue.NEXT:
729 await self.
apiapi.add_to_queue(
732 position=current_position + 1,
736 await self.
apiapi.add_to_queue(
739 position=current_position,
740 playback_from_position=current_position,
747 sleep_future = asyncio.create_task(
753 saved_song_position = self.
_player_player[
"item_progress_ms"]
754 saved_queue = self.
_queue_queue
if self.
_queue_queue[
"count"] > 0
else None
756 saved_queue_position = next(
758 for i, item
in enumerate(saved_queue[
"items"])
759 if item[
"id"] == self.
_player_player[
"item_id"]
763 await self.
apiapi.add_to_queue(uris=media_id, playback=
"start", clear=
True)
765 async
with asyncio.timeout(TTS_TIMEOUT):
770 _LOGGER.warning(
"TTS request timed out")
772 self.
_queue_queue[
"items"][0][
"length_ms"]
782 await self.
apiapi.add_to_queue(
785 if saved_state == MediaPlayerState.PLAYING:
791 await self.
apiapi.add_to_queue(
792 uris=
",".join(item[
"uri"]
for item
in saved_queue[
"items"]),
794 playback_from_position=saved_queue_position,
797 await self.
apiapi.seek(position_ms=saved_song_position)
798 if saved_state == MediaPlayerState.PAUSED:
801 if saved_state != MediaPlayerState.PLAYING:
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.
810 if source == self.
_source_source:
817 self.
_source_source = SOURCE_NAME_DEFAULT
819 await self.
apiapi.add_to_queue(uris=self.
_sources_uris_sources_uris[source], clear=
True)
820 elif source == SOURCE_NAME_CLEAR:
821 await self.
apiapi.clear_queue()
825 """Return which pipe control from KNOWN_PIPES to use."""
826 if self.
_source_source[-7:] ==
" (pipe)":
827 return self.
_source_source[:-7]
830 async
def _pipe_call(self, pipe_name, base_function_name) -> None:
834 PIPE_FUNCTION_MAP[pipe_name][base_function_name],
837 _LOGGER.warning(
"No pipe control available for %s", pipe_name)
841 media_content_type: MediaType | str |
None =
None,
842 media_content_id: str |
None =
None,
844 """Implement the websocket media browsing helper."""
845 if media_content_id
is None or media_source.is_media_source_id(
848 ms_result = await media_source.async_browse_media(
851 content_filter=
lambda bm: bm.media_content_type
in CAN_PLAY_TYPE,
853 if media_content_type
is not None:
855 other_sources: list[BrowseMedia] = (
856 list(ms_result.children)
if ms_result.children
else []
858 if "spotify" in self.
hasshass.config.components
and (
861 spotify_result = await spotify_async_browse_media(
862 self.
hasshass, media_content_type, media_content_id
864 if media_content_type
is not None:
865 return spotify_result
866 if spotify_result.children:
867 other_sources += spotify_result.children
869 if media_content_id
is None or media_content_type
is None:
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."""
884 if media_content_type
not in {
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():
897 (item
for item
in result
if item[
"id"] == item_id_str),
None
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"):
907 """Manage updates for the forked-daapd device."""
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"):
923 self.
_api_api.start_websocket_handler(
925 WS_NOTIFY_EVENT_TYPES,
927 WEBSOCKET_RECONNECT_TIME,
932 _LOGGER.error(
"Invalid websocket port")
935 """Send update signals when the websocket gets disconnected."""
937 self.
hasshass, SIGNAL_UPDATE_MASTER.format(self.
_entry_id_entry_id),
False
940 self.
hasshass, SIGNAL_UPDATE_OUTPUTS.format(self.
_entry_id_entry_id), []
944 """Private update method."""
945 update_types = set(update_types)
947 _LOGGER.debug(
"Updating %s", update_types)
949 "queue" in update_types
951 if queue := await self.
_api_api.get_request(
"queue"):
952 update_events[
"queue"] = asyncio.Event()
955 SIGNAL_UPDATE_QUEUE.format(self.
_entry_id_entry_id),
957 update_events[
"queue"],
960 if not {
"outputs",
"volume"}.isdisjoint(update_types):
961 if outputs := await self.
_api_api.get_request(
"outputs"):
962 outputs = outputs[
"outputs"]
963 update_events[
"outputs"] = (
968 SIGNAL_UPDATE_OUTPUTS.format(self.
_entry_id_entry_id),
970 update_events[
"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()
977 update_events[
"database"] = asyncio.Event()
980 SIGNAL_UPDATE_DATABASE.format(self.
_entry_id_entry_id),
983 update_events[
"database"],
985 if not {
"update",
"config"}.isdisjoint(update_types):
986 _LOGGER.debug(
"update/config notifications neither requested nor supported")
987 if not {
"player",
"options",
"volume"}.isdisjoint(
990 if player := await self.
_api_api.get_request(
"player"):
991 update_events[
"player"] = asyncio.Event()
992 if update_events.get(
"queue"):
998 SIGNAL_UPDATE_PLAYER.format(self.
_entry_id_entry_id),
1000 update_events[
"player"],
1004 [asyncio.create_task(event.wait())
for event
in update_events.values()]
1007 self.
hasshass, SIGNAL_UPDATE_MASTER.format(self.
_entry_id_entry_id),
True
1012 for output
in outputs:
1015 outputs_to_add.append(output)
1019 SIGNAL_ADD_ZONES.format(self.
_entry_id_entry_id),
None async_write_ha_state(self)
None async_on_remove(self, CALLBACK_TYPE func)
bool add(self, _T matcher)
web.Response get(self, web.Request request, str config_key)
IssData update(pyiss.ISS iss)
bool is_spotify_media_type(str media_content_type)
str resolve_spotify_media_type(str media_content_type)
str spotify_uri_from_media_browser_url(str media_content_id)
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)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)