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 .const import DOMAIN, LOGGER, SPOTIFY_SCOPES
from .models import HomeAssistantSpotifyData
from .coordinator import SpotifyCoordinator
from .models import SpotifyData
from .util import (
is_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:
@ -54,13 +55,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b
spotify = Spotify(auth=session.token["access_token"])
try:
current_user = await hass.async_add_executor_job(spotify.me)
except SpotifyException as err:
raise ConfigEntryNotReady from err
coordinator = SpotifyCoordinator(hass, spotify, session)
if not current_user:
raise ConfigEntryNotReady
await coordinator.async_config_entry_first_refresh()
async def _update_devices() -> list[dict[str, Any]]:
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()
entry.runtime_data = HomeAssistantSpotifyData(
client=spotify,
current_user=current_user,
devices=device_coordinator,
session=session,
)
entry.runtime_data = SpotifyData(coordinator, session, device_coordinator)
if not set(session.token["scope"].split(" ")).issuperset(SPOTIFY_SCOPES):
raise ConfigEntryAuthFailed

View file

@ -16,11 +16,11 @@ from homeassistant.components.media_player import (
MediaClass,
MediaType,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from .const import DOMAIN, MEDIA_PLAYER_PREFIX, MEDIA_TYPE_SHOW, PLAYABLE_MEDIA_TYPES
from .models import HomeAssistantSpotifyData
from .util import fetch_image_url
BROWSE_LIMIT = 48
@ -183,7 +183,7 @@ async def async_browse_media(
or hass.config_entries.async_get_entry(host.upper())
)
is None
or not isinstance(entry.runtime_data, HomeAssistantSpotifyData)
or entry.state is not ConfigEntryState.LOADED
):
raise BrowseError("Invalid Spotify account specified")
media_content_id = parsed_url.name
@ -191,9 +191,9 @@ async def async_browse_media(
result = await async_browse_media_internal(
hass,
info.client,
info.coordinator.client,
info.session,
info.current_user,
info.coordinator.current_user,
media_content_type,
media_content_id,
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 asyncio import run_coroutine_threadsafe
from collections.abc import Callable
import datetime as dt
from datetime import timedelta
import logging
from typing import Any, Concatenate
@ -27,12 +27,15 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
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 .browse_media import async_browse_media_internal
from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES
from .models import HomeAssistantSpotifyData
from .coordinator import SpotifyCoordinator
from .util import fetch_image_url
_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()
}
# 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(
hass: HomeAssistant,
@ -74,12 +73,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Spotify based on a config entry."""
data = entry.runtime_data
spotify = SpotifyMediaPlayer(
entry.runtime_data,
data.coordinator,
data.devices,
entry.data[CONF_ID],
entry.title,
)
async_add_entities([spotify], True)
async_add_entities([spotify])
def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R](
@ -110,7 +111,7 @@ def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R
return wrapper
class SpotifyMediaPlayer(MediaPlayerEntity):
class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntity):
"""Representation of a Spotify controller."""
_attr_has_entity_name = True
@ -120,97 +121,106 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
def __init__(
self,
data: HomeAssistantSpotifyData,
coordinator: SpotifyCoordinator,
device_coordinator: DataUpdateCoordinator[list[dict[str, Any]]],
user_id: str,
name: str,
) -> None:
"""Initialize."""
self._id = user_id
self.data = data
super().__init__(coordinator)
self.devices = device_coordinator
self._attr_unique_id = user_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, user_id)},
manufacturer="Spotify AB",
model=f"Spotify {data.current_user['product']}",
model=f"Spotify {coordinator.current_user['product']}",
name=f"Spotify {name}",
entry_type=DeviceEntryType.SERVICE,
configuration_url="https://open.spotify.com",
)
self._currently_playing: dict | None = {}
self._playlist: dict | None = None
self._restricted_device: bool = False
@property
def currently_playing(self) -> dict[str, Any]:
"""Return the current playback."""
return self.coordinator.data.current_playback
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Return the supported features."""
if self.data.current_user["product"] != "premium":
if self.coordinator.current_user["product"] != "premium":
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 SUPPORT_SPOTIFY
@property
def state(self) -> MediaPlayerState:
"""Return the playback state."""
if not self._currently_playing:
if not self.currently_playing:
return MediaPlayerState.IDLE
if self._currently_playing["is_playing"]:
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:
if not self.currently_playing:
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
def media_content_id(self) -> str | None:
"""Return the media URL."""
if not self._currently_playing:
if not self.currently_playing:
return None
item = self._currently_playing.get("item") or {}
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:
if not self.currently_playing:
return None
item = self._currently_playing.get("item") or {}
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
):
if self.currently_playing is None or self.currently_playing.get("item") is None:
return None
return self._currently_playing["item"]["duration_ms"] / 1000
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
not self.currently_playing
or self.currently_playing.get("progress_ms") is 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
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:
if not self.currently_playing or self.currently_playing.get("item") is None:
return None
item = self._currently_playing["item"]
item = self.currently_playing["item"]
if item["type"] == MediaType.EPISODE:
if item["images"]:
return fetch_image_url(item)
@ -225,18 +235,18 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
@property
def media_title(self) -> str | None:
"""Return the media title."""
if not self._currently_playing:
if not self.currently_playing:
return None
item = self._currently_playing.get("item") or {}
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:
if not self.currently_playing or self.currently_playing.get("item") is None:
return None
item = self._currently_playing["item"]
item = self.currently_playing["item"]
if item["type"] == MediaType.EPISODE:
return item["show"]["publisher"]
@ -245,10 +255,10 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
@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:
if not self.currently_playing or self.currently_playing.get("item") is None:
return None
item = self._currently_playing["item"]
item = self.currently_playing["item"]
if item["type"] == MediaType.EPISODE:
return item["show"]["name"]
@ -257,43 +267,43 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
@property
def media_track(self) -> int | None:
"""Track number of current playing media, music track only."""
if not self._currently_playing:
if not self.currently_playing:
return None
item = self._currently_playing.get("item") or {}
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:
if self.coordinator.data.playlist is None:
return None
return self._playlist["name"]
return self.coordinator.data.playlist["name"]
@property
def source(self) -> str | None:
"""Return the current playback device."""
if not self._currently_playing:
if not self.currently_playing:
return None
return self._currently_playing.get("device", {}).get("name")
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]
return [device["name"] for device in self.devices.data]
@property
def shuffle(self) -> bool | None:
"""Shuffling state."""
if not self._currently_playing:
if not self.currently_playing:
return None
return self._currently_playing.get("shuffle_state")
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
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)
@ -301,32 +311,32 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
@spotify_exception_handler
def set_volume_level(self, volume: float) -> None:
"""Set the volume level."""
self.data.client.volume(int(volume * 100))
self.coordinator.client.volume(int(volume * 100))
@spotify_exception_handler
def media_play(self) -> None:
"""Start or resume playback."""
self.data.client.start_playback()
self.coordinator.client.start_playback()
@spotify_exception_handler
def media_pause(self) -> None:
"""Pause playback."""
self.data.client.pause_playback()
self.coordinator.client.pause_playback()
@spotify_exception_handler
def media_previous_track(self) -> None:
"""Skip to previous track."""
self.data.client.previous_track()
self.coordinator.client.previous_track()
@spotify_exception_handler
def media_next_track(self) -> None:
"""Skip to next track."""
self.data.client.next_track()
self.coordinator.client.next_track()
@spotify_exception_handler
def media_seek(self, position: float) -> None:
"""Send seek command."""
self.data.client.seek_track(int(position * 1000))
self.coordinator.client.seek_track(int(position * 1000))
@spotify_exception_handler
def play_media(
@ -354,11 +364,11 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
return
if (
self._currently_playing
and not self._currently_playing.get("device")
and self.data.devices.data
self.currently_playing
and not self.currently_playing.get("device")
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 media_type not in {
@ -369,17 +379,17 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
raise ValueError(
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
self.data.client.start_playback(**kwargs)
self.coordinator.client.start_playback(**kwargs)
@spotify_exception_handler
def select_source(self, source: str) -> None:
"""Select playback device."""
for device in self.data.devices.data:
for device in self.devices.data:
if device["name"] == source:
self.data.client.transfer_playback(
self.coordinator.client.transfer_playback(
device["id"], self.state == MediaPlayerState.PLAYING
)
return
@ -387,66 +397,14 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
@spotify_exception_handler
def set_shuffle(self, shuffle: bool) -> None:
"""Enable/Disable shuffle mode."""
self.data.client.shuffle(shuffle)
self.coordinator.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 {}
# 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"]
self.coordinator.client.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat])
async def async_browse_media(
self,
@ -457,9 +415,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
return await async_browse_media_internal(
self.hass,
self.data.client,
self.data.session,
self.data.current_user,
self.coordinator.client,
self.coordinator.session,
self.coordinator.current_user,
media_content_type,
media_content_id,
)
@ -475,5 +433,5 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
"""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)
self.devices.async_add_listener(self._handle_devices_update)
)

View file

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

View file

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