1 """Support for interacting with Spotify Connect."""
3 from __future__
import annotations
6 from collections.abc
import Awaitable, Callable, Coroutine
9 from typing
import TYPE_CHECKING, Any, Concatenate
11 from spotifyaio
import (
18 RepeatMode
as SpotifyRepeatMode,
28 MediaPlayerEntityFeature,
37 from .browse_media
import async_browse_media_internal
38 from .const
import MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES
39 from .coordinator
import SpotifyConfigEntry, SpotifyCoordinator
40 from .entity
import SpotifyEntity
42 _LOGGER = logging.getLogger(__name__)
45 MediaPlayerEntityFeature.BROWSE_MEDIA
46 | MediaPlayerEntityFeature.NEXT_TRACK
47 | MediaPlayerEntityFeature.PAUSE
48 | MediaPlayerEntityFeature.PLAY
49 | MediaPlayerEntityFeature.PLAY_MEDIA
50 | MediaPlayerEntityFeature.PREVIOUS_TRACK
51 | MediaPlayerEntityFeature.REPEAT_SET
52 | MediaPlayerEntityFeature.SEEK
53 | MediaPlayerEntityFeature.SELECT_SOURCE
54 | MediaPlayerEntityFeature.SHUFFLE_SET
55 | MediaPlayerEntityFeature.VOLUME_SET
58 REPEAT_MODE_MAPPING_TO_HA = {
59 SpotifyRepeatMode.CONTEXT: RepeatMode.ALL,
60 SpotifyRepeatMode.OFF: RepeatMode.OFF,
61 SpotifyRepeatMode.TRACK: RepeatMode.ONE,
64 REPEAT_MODE_MAPPING_TO_SPOTIFY = {
65 value: key
for key, value
in REPEAT_MODE_MAPPING_TO_HA.items()
67 AFTER_REQUEST_SLEEP = 1
72 entry: SpotifyConfigEntry,
73 async_add_entities: AddEntitiesCallback,
75 """Set up Spotify based on a config entry."""
76 data = entry.runtime_data
77 assert entry.unique_id
is not None
86 func: Callable[[SpotifyMediaPlayer, Item], _R],
87 ) -> Callable[[SpotifyMediaPlayer], _R |
None]:
88 """Ensure that the currently playing item is available."""
90 def wrapper(self: SpotifyMediaPlayer) -> _R |
None:
91 if not self.currently_playing
or not self.currently_playing.item:
93 return func(self, self.currently_playing.item)
98 def async_refresh_after[_T: SpotifyEntity, **_P](
99 func: Callable[Concatenate[_T, _P], Awaitable[
None]],
100 ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any,
None]]:
101 """Define a wrapper to yield and refresh after."""
103 async
def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) ->
None:
104 await func(self, *args, **kwargs)
105 await asyncio.sleep(AFTER_REQUEST_SLEEP)
106 await self.coordinator.async_refresh()
112 """Representation of a Spotify controller."""
114 _attr_media_image_remotely_accessible =
False
116 _attr_translation_key =
"spotify"
120 coordinator: SpotifyCoordinator,
121 device_coordinator: DataUpdateCoordinator[list[Device]],
130 """Return the current playback."""
131 return self.coordinator.data.current_playback
135 """Return the supported features."""
136 if self.coordinator.current_user.product != ProductType.PREMIUM:
139 return MediaPlayerEntityFeature.SELECT_SOURCE
140 return SUPPORT_SPOTIFY
143 def state(self) -> MediaPlayerState:
144 """Return the playback state."""
146 return MediaPlayerState.IDLE
148 return MediaPlayerState.PLAYING
149 return MediaPlayerState.PAUSED
153 """Return the device volume."""
161 """Return the media URL."""
167 """Return the media type."""
168 return MediaType.PODCAST
if item.type == ItemType.EPISODE
else MediaType.MUSIC
173 """Duration of current playing media in seconds."""
174 return round(item.duration_ms / 1000)
178 """Position of current playing media in seconds."""
185 """When was the position of the current playing media valid."""
188 return self.coordinator.data.position_updated_at
193 """Return the media image URL."""
194 if item.type == ItemType.EPISODE:
196 assert isinstance(item, Episode)
198 return item.images[0].url
199 if item.show
and item.show.images:
200 return item.show.images[0].url
203 assert isinstance(item, Track)
204 if not item.album.images:
206 return item.album.images[0].url
211 """Return the media title."""
217 """Return the media artist."""
218 if item.type == ItemType.EPISODE:
220 assert isinstance(item, Episode)
221 return item.show.publisher
224 assert isinstance(item, Track)
225 return ", ".join(artist.name
for artist
in item.artists)
230 """Return the media album."""
231 if item.type == ItemType.EPISODE:
233 assert isinstance(item, Episode)
234 return item.show.name
237 assert isinstance(item, Track)
238 return item.album.name
243 """Track number of current playing media, music track only."""
244 if item.type == ItemType.EPISODE:
247 assert isinstance(item, Track)
248 return item.track_number
252 """Title of Playlist currently playing."""
253 if self.coordinator.data.dj_playlist:
255 if self.coordinator.data.playlist
is None:
257 return self.coordinator.data.playlist.name
261 """Return the current playback device."""
268 """Return a list of source devices."""
269 return [device.name
for device
in self.
devicesdevices.data]
273 """Shuffling state."""
280 """Return current repeat mode."""
283 return REPEAT_MODE_MAPPING_TO_HA.get(self.
currently_playingcurrently_playing.repeat_mode)
287 """Set the volume level."""
288 await self.coordinator.client.set_volume(
int(volume * 100))
292 """Start or resume playback."""
293 await self.coordinator.client.start_playback()
297 """Pause playback."""
298 await self.coordinator.client.pause_playback()
302 """Skip to previous track."""
303 await self.coordinator.client.previous_track()
307 """Skip to next track."""
308 await self.coordinator.client.next_track()
312 """Send seek command."""
313 await self.coordinator.client.seek_track(
int(position * 1000))
317 self, media_type: MediaType | str, media_id: str, **kwargs: Any
320 media_type = media_type.removeprefix(MEDIA_PLAYER_PREFIX)
322 enqueue: MediaPlayerEnqueue = kwargs.get(
323 ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE
330 media_id =
str(
URL(media_id).with_query(
None).with_fragment(
None))
332 if media_type
in {MediaType.TRACK, MediaType.EPISODE, MediaType.MUSIC}:
333 kwargs[
"uris"] = [media_id]
334 elif media_type
in PLAYABLE_MEDIA_TYPES:
335 kwargs[
"context_uri"] = media_id
337 _LOGGER.error(
"Media type %s is not supported", media_type)
341 kwargs[
"device_id"] = self.
devicesdevices.data[0].device_id
343 if enqueue == MediaPlayerEnqueue.ADD:
344 if media_type
not in {
350 f
"Media type {media_type} is not supported when enqueue is ADD"
352 await self.coordinator.client.add_to_queue(
353 media_id, kwargs.get(
"device_id")
357 await self.coordinator.client.start_playback(**kwargs)
361 """Select playback device."""
362 for device
in self.
devicesdevices.data:
363 if device.name == source:
365 assert device.device_id
is not None
366 await self.coordinator.client.transfer_playback(device.device_id)
371 """Enable/Disable shuffle mode."""
372 await self.coordinator.client.set_shuffle(state=shuffle)
376 """Set repeat mode."""
377 if repeat
not in REPEAT_MODE_MAPPING_TO_SPOTIFY:
378 raise ValueError(f
"Unsupported repeat mode: {repeat}")
379 await self.coordinator.client.set_repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat])
383 media_content_type: MediaType | str |
None =
None,
384 media_content_id: str |
None =
None,
386 """Implement the websocket media browsing helper."""
390 self.coordinator.client,
397 """Handle updated data from the coordinator."""
403 """When entity is added to hass."""
None async_write_ha_state(self)
None async_on_remove(self, CALLBACK_TYPE func)
Callable[[], None] async_add_listener(self, CALLBACK_TYPE update_callback, Any context=None)