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 .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
|
||||
|
|
|
@ -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,
|
||||
|
|
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 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)
|
||||
)
|
||||
|
|
|
@ -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]]]
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue