Add coordinator to Spotify (#123548)
This commit is contained in:
parent
c53a760ba3
commit
686d591f4f
6 changed files with 218 additions and 156 deletions
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
113
homeassistant/components/spotify/coordinator.py
Normal file
113
homeassistant/components/spotify/coordinator.py
Normal 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,
|
||||||
|
)
|
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
|
@ -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]]]
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Reference in a new issue