Add coordinator to Spotify (#123548)

This commit is contained in:
Joost Lekkerkerker 2024-09-24 22:24:40 +02:00 committed by GitHub
parent c53a760ba3
commit 686d591f4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 218 additions and 156 deletions

View file

@ -21,7 +21,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .browse_media import async_browse_media from .browse_media import async_browse_media
from .const import DOMAIN, LOGGER, SPOTIFY_SCOPES from .const import DOMAIN, LOGGER, SPOTIFY_SCOPES
from .models import HomeAssistantSpotifyData from .coordinator import SpotifyCoordinator
from .models import SpotifyData
from .util import ( from .util import (
is_spotify_media_type, is_spotify_media_type,
resolve_spotify_media_type, resolve_spotify_media_type,
@ -39,7 +40,7 @@ __all__ = [
] ]
type SpotifyConfigEntry = ConfigEntry[HomeAssistantSpotifyData] type SpotifyConfigEntry = ConfigEntry[SpotifyData]
async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> bool:
@ -54,13 +55,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b
spotify = Spotify(auth=session.token["access_token"]) spotify = Spotify(auth=session.token["access_token"])
try: coordinator = SpotifyCoordinator(hass, spotify, session)
current_user = await hass.async_add_executor_job(spotify.me)
except SpotifyException as err:
raise ConfigEntryNotReady from err
if not current_user: await coordinator.async_config_entry_first_refresh()
raise ConfigEntryNotReady
async def _update_devices() -> list[dict[str, Any]]: async def _update_devices() -> list[dict[str, Any]]:
if not session.valid_token: if not session.valid_token:
@ -92,12 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b
) )
await device_coordinator.async_config_entry_first_refresh() await device_coordinator.async_config_entry_first_refresh()
entry.runtime_data = HomeAssistantSpotifyData( entry.runtime_data = SpotifyData(coordinator, session, device_coordinator)
client=spotify,
current_user=current_user,
devices=device_coordinator,
session=session,
)
if not set(session.token["scope"].split(" ")).issuperset(SPOTIFY_SCOPES): if not set(session.token["scope"].split(" ")).issuperset(SPOTIFY_SCOPES):
raise ConfigEntryAuthFailed raise ConfigEntryAuthFailed

View file

@ -16,11 +16,11 @@ from homeassistant.components.media_player import (
MediaClass, MediaClass,
MediaType, MediaType,
) )
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from .const import DOMAIN, MEDIA_PLAYER_PREFIX, MEDIA_TYPE_SHOW, PLAYABLE_MEDIA_TYPES from .const import DOMAIN, MEDIA_PLAYER_PREFIX, MEDIA_TYPE_SHOW, PLAYABLE_MEDIA_TYPES
from .models import HomeAssistantSpotifyData
from .util import fetch_image_url from .util import fetch_image_url
BROWSE_LIMIT = 48 BROWSE_LIMIT = 48
@ -183,7 +183,7 @@ async def async_browse_media(
or hass.config_entries.async_get_entry(host.upper()) or hass.config_entries.async_get_entry(host.upper())
) )
is None is None
or not isinstance(entry.runtime_data, HomeAssistantSpotifyData) or entry.state is not ConfigEntryState.LOADED
): ):
raise BrowseError("Invalid Spotify account specified") raise BrowseError("Invalid Spotify account specified")
media_content_id = parsed_url.name media_content_id = parsed_url.name
@ -191,9 +191,9 @@ async def async_browse_media(
result = await async_browse_media_internal( result = await async_browse_media_internal(
hass, hass,
info.client, info.coordinator.client,
info.session, info.session,
info.current_user, info.coordinator.current_user,
media_content_type, media_content_type,
media_content_id, media_content_id,
can_play_artist=can_play_artist, can_play_artist=can_play_artist,

View file

@ -0,0 +1,113 @@
"""Coordinator for Spotify."""
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import Any
from spotipy import Spotify, SpotifyException
from homeassistant.components.media_player import MediaType
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import homeassistant.util.dt as dt_util
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@dataclass
class SpotifyCoordinatorData:
"""Class to hold Spotify data."""
current_playback: dict[str, Any]
position_updated_at: datetime | None
playlist: dict[str, Any] | None
# This is a minimal representation of the DJ playlist that Spotify now offers
# The DJ is not fully integrated with the playlist API, so needs to have the
# playlist response mocked in order to maintain functionality
SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": "DJ"}
class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
"""Class to manage fetching Spotify data."""
current_user: dict[str, Any]
def __init__(
self, hass: HomeAssistant, client: Spotify, session: OAuth2Session
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=30),
)
self.client = client
self._playlist: dict[str, Any] | None = None
self.session = session
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
self.current_user = await self.hass.async_add_executor_job(self.client.me)
except SpotifyException as err:
raise UpdateFailed("Error communicating with Spotify API") from err
if not self.current_user:
raise UpdateFailed("Could not retrieve user")
async def _async_update_data(self) -> SpotifyCoordinatorData:
if not self.session.valid_token:
await self.session.async_ensure_token_valid()
await self.hass.async_add_executor_job(
self.client.set_auth, self.session.token["access_token"]
)
return await self.hass.async_add_executor_job(self._sync_update_data)
def _sync_update_data(self) -> SpotifyCoordinatorData:
current = self.client.current_playback(additional_types=[MediaType.EPISODE])
currently_playing = current or {}
# Record the last updated time, because Spotify's timestamp property is unreliable
# and doesn't actually return the fetch time as is mentioned in the API description
position_updated_at = dt_util.utcnow() if current is not None else None
context = currently_playing.get("context") or {}
# For some users in some cases, the uri is formed like
# "spotify:user:{name}:playlist:{id}" and spotipy wants
# the type to be playlist.
uri = context.get("uri")
if uri is not None:
parts = uri.split(":")
if len(parts) == 5 and parts[1] == "user" and parts[3] == "playlist":
uri = ":".join([parts[0], parts[3], parts[4]])
if context and (self._playlist is None or self._playlist["uri"] != uri):
self._playlist = None
if context["type"] == MediaType.PLAYLIST:
# The Spotify API does not currently support doing a lookup for
# the DJ playlist,so just use the minimal mock playlist object
if uri == SPOTIFY_DJ_PLAYLIST["uri"]:
self._playlist = SPOTIFY_DJ_PLAYLIST
else:
# Make sure any playlist lookups don't break the current
# playback state update
try:
self._playlist = self.client.playlist(uri)
except SpotifyException:
_LOGGER.debug(
"Unable to load spotify playlist '%s'. "
"Continuing without playlist data",
uri,
)
self._playlist = None
return SpotifyCoordinatorData(
current_playback=currently_playing,
position_updated_at=position_updated_at,
playlist=self._playlist,
)

View file

@ -2,8 +2,8 @@
from __future__ import annotations from __future__ import annotations
from asyncio import run_coroutine_threadsafe
from collections.abc import Callable from collections.abc import Callable
import datetime as dt
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any, Concatenate from typing import Any, Concatenate
@ -27,12 +27,15 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.dt import utcnow from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from . import SpotifyConfigEntry from . import SpotifyConfigEntry
from .browse_media import async_browse_media_internal from .browse_media import async_browse_media_internal
from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES
from .models import HomeAssistantSpotifyData from .coordinator import SpotifyCoordinator
from .util import fetch_image_url from .util import fetch_image_url
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -63,10 +66,6 @@ REPEAT_MODE_MAPPING_TO_SPOTIFY = {
value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items()
} }
# This is a minimal representation of the DJ playlist that Spotify now offers
# The DJ is not fully integrated with the playlist API, so needs to have the playlist response mocked in order to maintain functionality
SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": "DJ"}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -74,12 +73,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Spotify based on a config entry.""" """Set up Spotify based on a config entry."""
data = entry.runtime_data
spotify = SpotifyMediaPlayer( spotify = SpotifyMediaPlayer(
entry.runtime_data, data.coordinator,
data.devices,
entry.data[CONF_ID], entry.data[CONF_ID],
entry.title, entry.title,
) )
async_add_entities([spotify], True) async_add_entities([spotify])
def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R]( def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R](
@ -110,7 +111,7 @@ def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R
return wrapper return wrapper
class SpotifyMediaPlayer(MediaPlayerEntity): class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntity):
"""Representation of a Spotify controller.""" """Representation of a Spotify controller."""
_attr_has_entity_name = True _attr_has_entity_name = True
@ -120,97 +121,106 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
def __init__( def __init__(
self, self,
data: HomeAssistantSpotifyData, coordinator: SpotifyCoordinator,
device_coordinator: DataUpdateCoordinator[list[dict[str, Any]]],
user_id: str, user_id: str,
name: str, name: str,
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
self._id = user_id super().__init__(coordinator)
self.data = data self.devices = device_coordinator
self._attr_unique_id = user_id self._attr_unique_id = user_id
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, user_id)}, identifiers={(DOMAIN, user_id)},
manufacturer="Spotify AB", manufacturer="Spotify AB",
model=f"Spotify {data.current_user['product']}", model=f"Spotify {coordinator.current_user['product']}",
name=f"Spotify {name}", name=f"Spotify {name}",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
configuration_url="https://open.spotify.com", configuration_url="https://open.spotify.com",
) )
self._currently_playing: dict | None = {}
self._playlist: dict | None = None @property
self._restricted_device: bool = False def currently_playing(self) -> dict[str, Any]:
"""Return the current playback."""
return self.coordinator.data.current_playback
@property @property
def supported_features(self) -> MediaPlayerEntityFeature: def supported_features(self) -> MediaPlayerEntityFeature:
"""Return the supported features.""" """Return the supported features."""
if self.data.current_user["product"] != "premium": if self.coordinator.current_user["product"] != "premium":
return MediaPlayerEntityFeature(0) return MediaPlayerEntityFeature(0)
if self._restricted_device or not self._currently_playing: if not self.currently_playing or self.currently_playing.get("device", {}).get(
"is_restricted"
):
return MediaPlayerEntityFeature.SELECT_SOURCE return MediaPlayerEntityFeature.SELECT_SOURCE
return SUPPORT_SPOTIFY return SUPPORT_SPOTIFY
@property @property
def state(self) -> MediaPlayerState: def state(self) -> MediaPlayerState:
"""Return the playback state.""" """Return the playback state."""
if not self._currently_playing: if not self.currently_playing:
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
if self._currently_playing["is_playing"]: if self.currently_playing["is_playing"]:
return MediaPlayerState.PLAYING return MediaPlayerState.PLAYING
return MediaPlayerState.PAUSED return MediaPlayerState.PAUSED
@property @property
def volume_level(self) -> float | None: def volume_level(self) -> float | None:
"""Return the device volume.""" """Return the device volume."""
if not self._currently_playing: if not self.currently_playing:
return None return None
return self._currently_playing.get("device", {}).get("volume_percent", 0) / 100 return self.currently_playing.get("device", {}).get("volume_percent", 0) / 100
@property @property
def media_content_id(self) -> str | None: def media_content_id(self) -> str | None:
"""Return the media URL.""" """Return the media URL."""
if not self._currently_playing: if not self.currently_playing:
return None return None
item = self._currently_playing.get("item") or {} item = self.currently_playing.get("item") or {}
return item.get("uri") return item.get("uri")
@property @property
def media_content_type(self) -> str | None: def media_content_type(self) -> str | None:
"""Return the media type.""" """Return the media type."""
if not self._currently_playing: if not self.currently_playing:
return None return None
item = self._currently_playing.get("item") or {} item = self.currently_playing.get("item") or {}
is_episode = item.get("type") == MediaType.EPISODE is_episode = item.get("type") == MediaType.EPISODE
return MediaType.PODCAST if is_episode else MediaType.MUSIC return MediaType.PODCAST if is_episode else MediaType.MUSIC
@property @property
def media_duration(self) -> int | None: def media_duration(self) -> int | None:
"""Duration of current playing media in seconds.""" """Duration of current playing media in seconds."""
if ( if self.currently_playing is None or self.currently_playing.get("item") is None:
self._currently_playing is None
or self._currently_playing.get("item") is None
):
return None return None
return self._currently_playing["item"]["duration_ms"] / 1000 return self.currently_playing["item"]["duration_ms"] / 1000
@property @property
def media_position(self) -> int | None: def media_position(self) -> int | None:
"""Position of current playing media in seconds.""" """Position of current playing media in seconds."""
if ( if (
not self._currently_playing not self.currently_playing
or self._currently_playing.get("progress_ms") is None or self.currently_playing.get("progress_ms") is None
): ):
return None return None
return self._currently_playing["progress_ms"] / 1000 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 self.coordinator.data.position_updated_at
@property @property
def media_image_url(self) -> str | None: def media_image_url(self) -> str | None:
"""Return the media image URL.""" """Return the media image URL."""
if not self._currently_playing or self._currently_playing.get("item") is None: if not self.currently_playing or self.currently_playing.get("item") is None:
return None return None
item = self._currently_playing["item"] item = self.currently_playing["item"]
if item["type"] == MediaType.EPISODE: if item["type"] == MediaType.EPISODE:
if item["images"]: if item["images"]:
return fetch_image_url(item) return fetch_image_url(item)
@ -225,18 +235,18 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
@property @property
def media_title(self) -> str | None: def media_title(self) -> str | None:
"""Return the media title.""" """Return the media title."""
if not self._currently_playing: if not self.currently_playing:
return None return None
item = self._currently_playing.get("item") or {} item = self.currently_playing.get("item") or {}
return item.get("name") return item.get("name")
@property @property
def media_artist(self) -> str | None: def media_artist(self) -> str | None:
"""Return the media artist.""" """Return the media artist."""
if not self._currently_playing or self._currently_playing.get("item") is None: if not self.currently_playing or self.currently_playing.get("item") is None:
return None return None
item = self._currently_playing["item"] item = self.currently_playing["item"]
if item["type"] == MediaType.EPISODE: if item["type"] == MediaType.EPISODE:
return item["show"]["publisher"] return item["show"]["publisher"]
@ -245,10 +255,10 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
@property @property
def media_album_name(self) -> str | None: def media_album_name(self) -> str | None:
"""Return the media album.""" """Return the media album."""
if not self._currently_playing or self._currently_playing.get("item") is None: if not self.currently_playing or self.currently_playing.get("item") is None:
return None return None
item = self._currently_playing["item"] item = self.currently_playing["item"]
if item["type"] == MediaType.EPISODE: if item["type"] == MediaType.EPISODE:
return item["show"]["name"] return item["show"]["name"]
@ -257,43 +267,43 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
@property @property
def media_track(self) -> int | None: def media_track(self) -> int | None:
"""Track number of current playing media, music track only.""" """Track number of current playing media, music track only."""
if not self._currently_playing: if not self.currently_playing:
return None return None
item = self._currently_playing.get("item") or {} item = self.currently_playing.get("item") or {}
return item.get("track_number") return item.get("track_number")
@property @property
def media_playlist(self): def media_playlist(self):
"""Title of Playlist currently playing.""" """Title of Playlist currently playing."""
if self._playlist is None: if self.coordinator.data.playlist is None:
return None return None
return self._playlist["name"] return self.coordinator.data.playlist["name"]
@property @property
def source(self) -> str | None: def source(self) -> str | None:
"""Return the current playback device.""" """Return the current playback device."""
if not self._currently_playing: if not self.currently_playing:
return None return None
return self._currently_playing.get("device", {}).get("name") return self.currently_playing.get("device", {}).get("name")
@property @property
def source_list(self) -> list[str] | None: def source_list(self) -> list[str] | None:
"""Return a list of source devices.""" """Return a list of source devices."""
return [device["name"] for device in self.data.devices.data] return [device["name"] for device in self.devices.data]
@property @property
def shuffle(self) -> bool | None: def shuffle(self) -> bool | None:
"""Shuffling state.""" """Shuffling state."""
if not self._currently_playing: if not self.currently_playing:
return None return None
return self._currently_playing.get("shuffle_state") return self.currently_playing.get("shuffle_state")
@property @property
def repeat(self) -> RepeatMode | None: def repeat(self) -> RepeatMode | None:
"""Return current repeat mode.""" """Return current repeat mode."""
if ( if (
not self._currently_playing not self.currently_playing
or (repeat_state := self._currently_playing.get("repeat_state")) is None or (repeat_state := self.currently_playing.get("repeat_state")) is None
): ):
return None return None
return REPEAT_MODE_MAPPING_TO_HA.get(repeat_state) return REPEAT_MODE_MAPPING_TO_HA.get(repeat_state)
@ -301,32 +311,32 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
@spotify_exception_handler @spotify_exception_handler
def set_volume_level(self, volume: float) -> None: def set_volume_level(self, volume: float) -> None:
"""Set the volume level.""" """Set the volume level."""
self.data.client.volume(int(volume * 100)) self.coordinator.client.volume(int(volume * 100))
@spotify_exception_handler @spotify_exception_handler
def media_play(self) -> None: def media_play(self) -> None:
"""Start or resume playback.""" """Start or resume playback."""
self.data.client.start_playback() self.coordinator.client.start_playback()
@spotify_exception_handler @spotify_exception_handler
def media_pause(self) -> None: def media_pause(self) -> None:
"""Pause playback.""" """Pause playback."""
self.data.client.pause_playback() self.coordinator.client.pause_playback()
@spotify_exception_handler @spotify_exception_handler
def media_previous_track(self) -> None: def media_previous_track(self) -> None:
"""Skip to previous track.""" """Skip to previous track."""
self.data.client.previous_track() self.coordinator.client.previous_track()
@spotify_exception_handler @spotify_exception_handler
def media_next_track(self) -> None: def media_next_track(self) -> None:
"""Skip to next track.""" """Skip to next track."""
self.data.client.next_track() self.coordinator.client.next_track()
@spotify_exception_handler @spotify_exception_handler
def media_seek(self, position: float) -> None: def media_seek(self, position: float) -> None:
"""Send seek command.""" """Send seek command."""
self.data.client.seek_track(int(position * 1000)) self.coordinator.client.seek_track(int(position * 1000))
@spotify_exception_handler @spotify_exception_handler
def play_media( def play_media(
@ -354,11 +364,11 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
return return
if ( if (
self._currently_playing self.currently_playing
and not self._currently_playing.get("device") and not self.currently_playing.get("device")
and self.data.devices.data and self.devices.data
): ):
kwargs["device_id"] = self.data.devices.data[0].get("id") kwargs["device_id"] = self.devices.data[0].get("id")
if enqueue == MediaPlayerEnqueue.ADD: if enqueue == MediaPlayerEnqueue.ADD:
if media_type not in { if media_type not in {
@ -369,17 +379,17 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
raise ValueError( raise ValueError(
f"Media type {media_type} is not supported when enqueue is ADD" f"Media type {media_type} is not supported when enqueue is ADD"
) )
self.data.client.add_to_queue(media_id, kwargs.get("device_id")) self.coordinator.client.add_to_queue(media_id, kwargs.get("device_id"))
return return
self.data.client.start_playback(**kwargs) self.coordinator.client.start_playback(**kwargs)
@spotify_exception_handler @spotify_exception_handler
def select_source(self, source: str) -> None: def select_source(self, source: str) -> None:
"""Select playback device.""" """Select playback device."""
for device in self.data.devices.data: for device in self.devices.data:
if device["name"] == source: if device["name"] == source:
self.data.client.transfer_playback( self.coordinator.client.transfer_playback(
device["id"], self.state == MediaPlayerState.PLAYING device["id"], self.state == MediaPlayerState.PLAYING
) )
return return
@ -387,66 +397,14 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
@spotify_exception_handler @spotify_exception_handler
def set_shuffle(self, shuffle: bool) -> None: def set_shuffle(self, shuffle: bool) -> None:
"""Enable/Disable shuffle mode.""" """Enable/Disable shuffle mode."""
self.data.client.shuffle(shuffle) self.coordinator.client.shuffle(shuffle)
@spotify_exception_handler @spotify_exception_handler
def set_repeat(self, repeat: RepeatMode) -> None: def set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode.""" """Set repeat mode."""
if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY: if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY:
raise ValueError(f"Unsupported repeat mode: {repeat}") raise ValueError(f"Unsupported repeat mode: {repeat}")
self.data.client.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat]) self.coordinator.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 {}
# Record the last updated time, because Spotify's timestamp property is unreliable
# and doesn't actually return the fetch time as is mentioned in the API description
self._attr_media_position_updated_at = utcnow() if current is not None else None
context = self._currently_playing.get("context") or {}
# For some users in some cases, the uri is formed like
# "spotify:user:{name}:playlist:{id}" and spotipy wants
# the type to be playlist.
uri = context.get("uri")
if uri is not None:
parts = uri.split(":")
if len(parts) == 5 and parts[1] == "user" and parts[3] == "playlist":
uri = ":".join([parts[0], parts[3], parts[4]])
if context and (self._playlist is None or self._playlist["uri"] != uri):
self._playlist = None
if context["type"] == MediaType.PLAYLIST:
# The Spotify API does not currently support doing a lookup for the DJ playlist, so just use the minimal mock playlist object
if uri == SPOTIFY_DJ_PLAYLIST["uri"]:
self._playlist = SPOTIFY_DJ_PLAYLIST
else:
# Make sure any playlist lookups don't break the current playback state update
try:
self._playlist = self.data.client.playlist(uri)
except SpotifyException:
_LOGGER.debug(
"Unable to load spotify playlist '%s'. Continuing without playlist data",
uri,
)
self._playlist = None
device = self._currently_playing.get("device")
if device is not None:
self._restricted_device = device["is_restricted"]
async def async_browse_media( async def async_browse_media(
self, self,
@ -457,9 +415,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
return await async_browse_media_internal( return await async_browse_media_internal(
self.hass, self.hass,
self.data.client, self.coordinator.client,
self.data.session, self.coordinator.session,
self.data.current_user, self.coordinator.current_user,
media_content_type, media_content_type,
media_content_id, media_content_id,
) )
@ -475,5 +433,5 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
"""When entity is added to hass.""" """When entity is added to hass."""
await super().async_added_to_hass() await super().async_added_to_hass()
self.async_on_remove( self.async_on_remove(
self.data.devices.async_add_listener(self._handle_devices_update) self.devices.async_add_listener(self._handle_devices_update)
) )

View file

@ -3,17 +3,16 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from spotipy import Spotify
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .coordinator import SpotifyCoordinator
@dataclass @dataclass
class HomeAssistantSpotifyData: class SpotifyData:
"""Spotify data stored in the Home Assistant data object.""" """Class to hold Spotify data."""
client: Spotify coordinator: SpotifyCoordinator
current_user: dict[str, Any]
devices: DataUpdateCoordinator[list[dict[str, Any]]]
session: OAuth2Session session: OAuth2Session
devices: DataUpdateCoordinator[list[dict[str, Any]]]

View file

@ -10,12 +10,14 @@ from homeassistant.components.application_credentials import (
ClientCredential, ClientCredential,
async_import_client_credential, async_import_client_credential,
) )
from homeassistant.components.spotify import DOMAIN from homeassistant.components.spotify.const import DOMAIN, SPOTIFY_SCOPES
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
SCOPES = " ".join(SPOTIFY_SCOPES)
@pytest.fixture @pytest.fixture
def mock_config_entry_1() -> MockConfigEntry: def mock_config_entry_1() -> MockConfigEntry:
@ -30,7 +32,7 @@ def mock_config_entry_1() -> MockConfigEntry:
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 3600, "expires_in": 3600,
"refresh_token": "RefreshToken", "refresh_token": "RefreshToken",
"scope": "playlist-read-private ...", "scope": SCOPES,
"expires_at": 1724198975.8829377, "expires_at": 1724198975.8829377,
}, },
"id": "32oesphrnacjcf7vw5bf6odx3oiu", "id": "32oesphrnacjcf7vw5bf6odx3oiu",
@ -54,7 +56,7 @@ def mock_config_entry_2() -> MockConfigEntry:
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 3600, "expires_in": 3600,
"refresh_token": "RefreshToken", "refresh_token": "RefreshToken",
"scope": "playlist-read-private ...", "scope": SCOPES,
"expires_at": 1724198975.8829377, "expires_at": 1724198975.8829377,
}, },
"id": "55oesphrnacjcf7vw5bf6odx3oiu", "id": "55oesphrnacjcf7vw5bf6odx3oiu",
@ -123,6 +125,4 @@ async def spotify_setup(
mock_config_entry_2.add_to_hass(hass) mock_config_entry_2.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry_2.entry_id) await hass.config_entries.async_setup(mock_config_entry_2.entry_id)
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done(wait_background_tasks=True)
yield yield