"""Support for interacting with Spotify Connect.""" from __future__ import annotations from asyncio import run_coroutine_threadsafe import datetime as dt from datetime import timedelta import logging from typing import Any import requests from spotipy import SpotifyException from yarl import URL from homeassistant.components.media_player import ( BrowseMedia, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, RepeatMode, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utc_from_timestamp from . import HomeAssistantSpotifyData from .browse_media import async_browse_media_internal from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES, SPOTIFY_SCOPES from .util import fetch_image_url _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=30) SUPPORT_SPOTIFY = ( MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.VOLUME_SET ) REPEAT_MODE_MAPPING_TO_HA = { "context": RepeatMode.ALL, "off": RepeatMode.OFF, "track": RepeatMode.ONE, } REPEAT_MODE_MAPPING_TO_SPOTIFY = { value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() } async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Spotify based on a config entry.""" spotify = SpotifyMediaPlayer( hass.data[DOMAIN][entry.entry_id], entry.data[CONF_ID], entry.title, ) async_add_entities([spotify], True) def spotify_exception_handler(func): """Decorate Spotify calls to handle Spotify exception. A decorator that wraps the passed in function, catches Spotify errors, aiohttp exceptions and handles the availability of the media player. """ def wrapper(self, *args, **kwargs): # pylint: disable=protected-access try: result = func(self, *args, **kwargs) self._attr_available = True return result except requests.RequestException: self._attr_available = False except SpotifyException as exc: self._attr_available = False if exc.reason == "NO_ACTIVE_DEVICE": raise HomeAssistantError("No active playback device found") from None raise HomeAssistantError(f"Spotify error: {exc.reason}") from exc return wrapper class SpotifyMediaPlayer(MediaPlayerEntity): """Representation of a Spotify controller.""" _attr_has_entity_name = True _attr_icon = "mdi:spotify" _attr_media_image_remotely_accessible = False _attr_name = None def __init__( self, data: HomeAssistantSpotifyData, user_id: str, name: str, ) -> None: """Initialize.""" self._id = user_id self.data = data self._attr_unique_id = user_id if self.data.current_user["product"] == "premium": self._attr_supported_features = SUPPORT_SPOTIFY self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, user_id)}, manufacturer="Spotify AB", model=f"Spotify {data.current_user['product']}", name=f"Spotify {name}", entry_type=DeviceEntryType.SERVICE, configuration_url="https://open.spotify.com", ) self._scope_ok = set(data.session.token["scope"].split(" ")).issuperset( SPOTIFY_SCOPES ) self._currently_playing: dict | None = {} self._playlist: dict | None = None @property def state(self) -> MediaPlayerState: """Return the playback state.""" if not self._currently_playing: return MediaPlayerState.IDLE if self._currently_playing["is_playing"]: return MediaPlayerState.PLAYING return MediaPlayerState.PAUSED @property def volume_level(self) -> float | None: """Return the device volume.""" if not self._currently_playing: return None return self._currently_playing.get("device", {}).get("volume_percent", 0) / 100 @property def media_content_id(self) -> str | None: """Return the media URL.""" if not self._currently_playing: return None item = self._currently_playing.get("item") or {} return item.get("uri") @property def media_content_type(self) -> str | None: """Return the media type.""" if not self._currently_playing: return None item = self._currently_playing.get("item") or {} is_episode = item.get("type") == MediaType.EPISODE return MediaType.PODCAST if is_episode else MediaType.MUSIC @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" if ( self._currently_playing is None or self._currently_playing.get("item") is None ): return None return self._currently_playing["item"]["duration_ms"] / 1000 @property def media_position(self) -> int | None: """Position of current playing media in seconds.""" if ( not self._currently_playing or self._currently_playing.get("progress_ms") is None ): return None return self._currently_playing["progress_ms"] / 1000 @property def media_position_updated_at(self) -> dt.datetime | None: """When was the position of the current playing media valid.""" if not self._currently_playing: return None return utc_from_timestamp(self._currently_playing["timestamp"] / 1000) @property def media_image_url(self) -> str | None: """Return the media image URL.""" if not self._currently_playing or self._currently_playing.get("item") is None: return None item = self._currently_playing["item"] if item["type"] == MediaType.EPISODE: if item["images"]: return fetch_image_url(item) if item["show"]["images"]: return fetch_image_url(item["show"]) return None if not item["album"]["images"]: return None return fetch_image_url(item["album"]) @property def media_title(self) -> str | None: """Return the media title.""" if not self._currently_playing: return None item = self._currently_playing.get("item") or {} return item.get("name") @property def media_artist(self) -> str | None: """Return the media artist.""" if not self._currently_playing or self._currently_playing.get("item") is None: return None item = self._currently_playing["item"] if item["type"] == MediaType.EPISODE: return item["show"]["publisher"] return ", ".join(artist["name"] for artist in item["artists"]) @property def media_album_name(self) -> str | None: """Return the media album.""" if not self._currently_playing or self._currently_playing.get("item") is None: return None item = self._currently_playing["item"] if item["type"] == MediaType.EPISODE: return item["show"]["name"] return item["album"]["name"] @property def media_track(self) -> int | None: """Track number of current playing media, music track only.""" if not self._currently_playing: return None item = self._currently_playing.get("item") or {} return item.get("track_number") @property def media_playlist(self): """Title of Playlist currently playing.""" if self._playlist is None: return None return self._playlist["name"] @property def source(self) -> str | None: """Return the current playback device.""" if not self._currently_playing: return None return self._currently_playing.get("device", {}).get("name") @property def source_list(self) -> list[str] | None: """Return a list of source devices.""" return [device["name"] for device in self.data.devices.data] @property def shuffle(self) -> bool | None: """Shuffling state.""" if not self._currently_playing: return None return self._currently_playing.get("shuffle_state") @property def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" if ( not self._currently_playing or (repeat_state := self._currently_playing.get("repeat_state")) is None ): return None return REPEAT_MODE_MAPPING_TO_HA.get(repeat_state) @spotify_exception_handler def set_volume_level(self, volume: float) -> None: """Set the volume level.""" self.data.client.volume(int(volume * 100)) @spotify_exception_handler def media_play(self) -> None: """Start or resume playback.""" self.data.client.start_playback() @spotify_exception_handler def media_pause(self) -> None: """Pause playback.""" self.data.client.pause_playback() @spotify_exception_handler def media_previous_track(self) -> None: """Skip to previous track.""" self.data.client.previous_track() @spotify_exception_handler def media_next_track(self) -> None: """Skip to next track.""" self.data.client.next_track() @spotify_exception_handler def media_seek(self, position: float) -> None: """Send seek command.""" self.data.client.seek_track(int(position * 1000)) @spotify_exception_handler def play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media.""" media_type = media_type.removeprefix(MEDIA_PLAYER_PREFIX) kwargs = {} # Spotify can't handle URI's with query strings or anchors # Yet, they do generate those types of URI in their official clients. media_id = str(URL(media_id).with_query(None).with_fragment(None)) if media_type in {MediaType.TRACK, MediaType.EPISODE, MediaType.MUSIC}: kwargs["uris"] = [media_id] elif media_type in PLAYABLE_MEDIA_TYPES: kwargs["context_uri"] = media_id else: _LOGGER.error("Media type %s is not supported", media_type) return if ( self._currently_playing and not self._currently_playing.get("device") and self.data.devices.data ): kwargs["device_id"] = self.data.devices.data[0].get("id") self.data.client.start_playback(**kwargs) @spotify_exception_handler def select_source(self, source: str) -> None: """Select playback device.""" for device in self.data.devices.data: if device["name"] == source: self.data.client.transfer_playback( device["id"], self.state == MediaPlayerState.PLAYING ) return @spotify_exception_handler def set_shuffle(self, shuffle: bool) -> None: """Enable/Disable shuffle mode.""" self.data.client.shuffle(shuffle) @spotify_exception_handler def set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY: raise ValueError(f"Unsupported repeat mode: {repeat}") self.data.client.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat]) @spotify_exception_handler def update(self) -> None: """Update state and attributes.""" if not self.enabled: return if not self.data.session.valid_token or self.data.client is None: run_coroutine_threadsafe( self.data.session.async_ensure_token_valid(), self.hass.loop ).result() self.data.client.set_auth(auth=self.data.session.token["access_token"]) current = self.data.client.current_playback( additional_types=[MediaType.EPISODE] ) self._currently_playing = current or {} context = self._currently_playing.get("context") if context is not None and ( self._playlist is None or self._playlist["uri"] != context["uri"] ): self._playlist = None if context["type"] == MediaType.PLAYLIST: self._playlist = self.data.client.playlist(current["context"]["uri"]) async def async_browse_media( self, media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" if not self._scope_ok: _LOGGER.debug( "Spotify scopes are not set correctly, this can impact features such as" " media browsing" ) raise NotImplementedError return await async_browse_media_internal( self.hass, self.data.client, self.data.session, self.data.current_user, media_content_type, media_content_id, ) @callback def _handle_devices_update(self) -> None: """Handle updated data from the coordinator.""" if not self.enabled: return self.async_write_ha_state() async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() self.async_on_remove( self.data.devices.async_add_listener(self._handle_devices_update) )