Migrate spotify to aiospotify (#127728)

This commit is contained in:
Joost Lekkerkerker 2024-10-16 17:04:05 +02:00 committed by GitHub
parent 11ac8f8006
commit 494511e099
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 511 additions and 481 deletions

View File

@ -3,16 +3,16 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import TYPE_CHECKING
import aiohttp import aiohttp
import requests from spotifyaio import Device, SpotifyClient, SpotifyConnectionError
from spotipy import Spotify, SpotifyException
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import ( from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session, OAuth2Session,
async_get_config_entry_implementation, async_get_config_entry_implementation,
@ -53,40 +53,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
spotify = Spotify(auth=session.token["access_token"]) spotify = SpotifyClient(async_get_clientsession(hass))
coordinator = SpotifyCoordinator(hass, spotify, session) spotify.authenticate(session.token[CONF_ACCESS_TOKEN])
async def _refresh_token() -> str:
await session.async_ensure_token_valid()
token = session.token[CONF_ACCESS_TOKEN]
if TYPE_CHECKING:
assert isinstance(token, str)
return token
spotify.refresh_token_function = _refresh_token
coordinator = SpotifyCoordinator(hass, spotify)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
async def _update_devices() -> list[dict[str, Any]]: async def _update_devices() -> list[Device]:
if not session.valid_token:
await session.async_ensure_token_valid()
await hass.async_add_executor_job(
spotify.set_auth, session.token["access_token"]
)
try: try:
devices: dict[str, Any] | None = await hass.async_add_executor_job( return await spotify.get_devices()
spotify.devices except SpotifyConnectionError as err:
)
except (requests.RequestException, SpotifyException) as err:
raise UpdateFailed from err raise UpdateFailed from err
if devices is None: device_coordinator: DataUpdateCoordinator[list[Device]] = DataUpdateCoordinator(
return []
return devices.get("devices", [])
device_coordinator: DataUpdateCoordinator[list[dict[str, Any]]] = (
DataUpdateCoordinator(
hass, hass,
LOGGER, LOGGER,
name=f"{entry.title} Devices", name=f"{entry.title} Devices",
update_interval=timedelta(minutes=5), update_interval=timedelta(minutes=5),
update_method=_update_devices, update_method=_update_devices,
) )
)
await device_coordinator.async_config_entry_first_refresh() await device_coordinator.async_config_entry_first_refresh()
entry.runtime_data = SpotifyData(coordinator, session, device_coordinator) entry.runtime_data = SpotifyData(coordinator, session, device_coordinator)

View File

@ -3,11 +3,17 @@
from __future__ import annotations from __future__ import annotations
from enum import StrEnum from enum import StrEnum
from functools import partial
import logging import logging
from typing import Any from typing import TYPE_CHECKING, Any, TypedDict
from spotipy import Spotify from spotifyaio import (
Artist,
BasePlaylist,
SimplifiedAlbum,
SimplifiedTrack,
SpotifyClient,
Track,
)
import yarl import yarl
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
@ -18,7 +24,6 @@ from homeassistant.components.media_player import (
) )
from homeassistant.config_entries import ConfigEntryState 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 .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 .util import fetch_image_url from .util import fetch_image_url
@ -29,6 +34,62 @@ BROWSE_LIMIT = 48
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class ItemPayload(TypedDict):
"""TypedDict for item payload."""
name: str
type: str
uri: str
id: str | None
thumbnail: str | None
def _get_artist_item_payload(artist: Artist) -> ItemPayload:
return {
"id": artist.artist_id,
"name": artist.name,
"type": MediaType.ARTIST,
"uri": artist.uri,
"thumbnail": fetch_image_url(artist.images),
}
def _get_album_item_payload(album: SimplifiedAlbum) -> ItemPayload:
return {
"id": album.album_id,
"name": album.name,
"type": MediaType.ALBUM,
"uri": album.uri,
"thumbnail": fetch_image_url(album.images),
}
def _get_playlist_item_payload(playlist: BasePlaylist) -> ItemPayload:
return {
"id": playlist.playlist_id,
"name": playlist.name,
"type": MediaType.PLAYLIST,
"uri": playlist.uri,
"thumbnail": fetch_image_url(playlist.images),
}
def _get_track_item_payload(
track: SimplifiedTrack, show_thumbnails: bool = True
) -> ItemPayload:
return {
"id": track.track_id,
"name": track.name,
"type": MediaType.TRACK,
"uri": track.uri,
"thumbnail": (
fetch_image_url(track.album.images)
if show_thumbnails and isinstance(track, Track)
else None
),
}
class BrowsableMedia(StrEnum): class BrowsableMedia(StrEnum):
"""Enum of browsable media.""" """Enum of browsable media."""
@ -192,14 +253,13 @@ async def async_browse_media(
result = await async_browse_media_internal( result = await async_browse_media_internal(
hass, hass,
info.coordinator.client, info.coordinator.client,
info.session,
info.coordinator.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,
) )
# Build new URLs with config entry specifyers # Build new URLs with config entry specifiers
result.media_content_id = str(parsed_url.with_name(result.media_content_id)) result.media_content_id = str(parsed_url.with_name(result.media_content_id))
if result.children: if result.children:
for child in result.children: for child in result.children:
@ -209,8 +269,7 @@ async def async_browse_media(
async def async_browse_media_internal( async def async_browse_media_internal(
hass: HomeAssistant, hass: HomeAssistant,
spotify: Spotify, spotify: SpotifyClient,
session: OAuth2Session,
current_user: dict[str, Any], current_user: dict[str, Any],
media_content_type: str | None, media_content_type: str | None,
media_content_id: str | None, media_content_id: str | None,
@ -219,15 +278,7 @@ async def async_browse_media_internal(
) -> BrowseMedia: ) -> BrowseMedia:
"""Browse spotify media.""" """Browse spotify media."""
if media_content_type in (None, f"{MEDIA_PLAYER_PREFIX}library"): if media_content_type in (None, f"{MEDIA_PLAYER_PREFIX}library"):
return await hass.async_add_executor_job( return await library_payload(can_play_artist=can_play_artist)
partial(library_payload, can_play_artist=can_play_artist)
)
if not session.valid_token:
await session.async_ensure_token_valid()
await hass.async_add_executor_job(
spotify.set_auth, session.token["access_token"]
)
# Strip prefix # Strip prefix
if media_content_type: if media_content_type:
@ -237,22 +288,19 @@ async def async_browse_media_internal(
"media_content_type": media_content_type, "media_content_type": media_content_type,
"media_content_id": media_content_id, "media_content_id": media_content_id,
} }
response = await hass.async_add_executor_job( response = await build_item_response(
partial(
build_item_response,
spotify, spotify,
current_user, current_user,
payload, payload,
can_play_artist=can_play_artist, can_play_artist=can_play_artist,
) )
)
if response is None: if response is None:
raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}") raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}")
return response return response
def build_item_response( # noqa: C901 async def build_item_response( # noqa: C901
spotify: Spotify, spotify: SpotifyClient,
user: dict[str, Any], user: dict[str, Any],
payload: dict[str, str | None], payload: dict[str, str | None],
*, *,
@ -265,80 +313,112 @@ def build_item_response( # noqa: C901
if media_content_type is None or media_content_id is None: if media_content_type is None or media_content_id is None:
return None return None
title = None title: str | None = None
image = None image: str | None = None
media: dict[str, Any] | None = None items: list[ItemPayload] = []
items = []
if media_content_type == BrowsableMedia.CURRENT_USER_PLAYLISTS: if media_content_type == BrowsableMedia.CURRENT_USER_PLAYLISTS:
if media := spotify.current_user_playlists(limit=BROWSE_LIMIT): if playlists := await spotify.get_playlists_for_current_user():
items = media.get("items", []) items = [_get_playlist_item_payload(playlist) for playlist in playlists]
elif media_content_type == BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS: elif media_content_type == BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS:
if media := spotify.current_user_followed_artists(limit=BROWSE_LIMIT): if artists := await spotify.get_followed_artists():
items = media.get("artists", {}).get("items", []) items = [_get_artist_item_payload(artist) for artist in artists]
elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_ALBUMS: elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_ALBUMS:
if media := spotify.current_user_saved_albums(limit=BROWSE_LIMIT): if saved_albums := await spotify.get_saved_albums():
items = [item["album"] for item in media.get("items", [])] items = [
_get_album_item_payload(saved_album.album)
for saved_album in saved_albums
]
elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_TRACKS: elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_TRACKS:
if media := spotify.current_user_saved_tracks(limit=BROWSE_LIMIT): if media := await spotify.get_saved_tracks():
items = [item["track"] for item in media.get("items", [])] items = [
_get_track_item_payload(saved_track.track) for saved_track in media
]
elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_SHOWS: elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_SHOWS:
if media := spotify.current_user_saved_shows(limit=BROWSE_LIMIT): if media := await spotify.get_saved_shows():
items = [item["show"] for item in media.get("items", [])] items = [
{
"id": saved_show.show.show_id,
"name": saved_show.show.name,
"type": MEDIA_TYPE_SHOW,
"uri": saved_show.show.uri,
"thumbnail": fetch_image_url(saved_show.show.images),
}
for saved_show in media
]
elif media_content_type == BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED: elif media_content_type == BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED:
if media := spotify.current_user_recently_played(limit=BROWSE_LIMIT): if media := await spotify.get_recently_played_tracks():
items = [item["track"] for item in media.get("items", [])] items = [_get_track_item_payload(item.track) for item in media]
elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_ARTISTS: elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_ARTISTS:
if media := spotify.current_user_top_artists(limit=BROWSE_LIMIT): if media := await spotify.get_top_artists():
items = media.get("items", []) items = [_get_artist_item_payload(artist) for artist in media]
elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS: elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS:
if media := spotify.current_user_top_tracks(limit=BROWSE_LIMIT): if media := await spotify.get_top_tracks():
items = media.get("items", []) items = [_get_track_item_payload(track) for track in media]
elif media_content_type == BrowsableMedia.FEATURED_PLAYLISTS: elif media_content_type == BrowsableMedia.FEATURED_PLAYLISTS:
if media := spotify.featured_playlists( if media := await spotify.get_featured_playlists():
country=user["country"], limit=BROWSE_LIMIT items = [_get_playlist_item_payload(playlist) for playlist in media]
):
items = media.get("playlists", {}).get("items", [])
elif media_content_type == BrowsableMedia.CATEGORIES: elif media_content_type == BrowsableMedia.CATEGORIES:
if media := spotify.categories(country=user["country"], limit=BROWSE_LIMIT): if media := await spotify.get_categories():
items = media.get("categories", {}).get("items", []) items = [
{
"id": category.category_id,
"name": category.name,
"type": "category_playlists",
"uri": category.category_id,
"thumbnail": category.icons[0].url if category.icons else None,
}
for category in media
]
elif media_content_type == "category_playlists": elif media_content_type == "category_playlists":
if ( if (
media := spotify.category_playlists( media := await spotify.get_category_playlists(category_id=media_content_id)
category_id=media_content_id, ) and (category := await spotify.get_category(media_content_id)):
country=user["country"], title = category.name
limit=BROWSE_LIMIT, image = category.icons[0].url if category.icons else None
) items = [_get_playlist_item_payload(playlist) for playlist in media]
) and (category := spotify.category(media_content_id, country=user["country"])):
title = category.get("name")
image = fetch_image_url(category, key="icons")
items = media.get("playlists", {}).get("items", [])
elif media_content_type == BrowsableMedia.NEW_RELEASES: elif media_content_type == BrowsableMedia.NEW_RELEASES:
if media := spotify.new_releases(country=user["country"], limit=BROWSE_LIMIT): if media := await spotify.get_new_releases():
items = media.get("albums", {}).get("items", []) items = [_get_album_item_payload(album) for album in media]
elif media_content_type == MediaType.PLAYLIST: elif media_content_type == MediaType.PLAYLIST:
if media := spotify.playlist(media_content_id): if media := await spotify.get_playlist(media_content_id):
items = [item["track"] for item in media.get("tracks", {}).get("items", [])] title = media.name
image = media.images[0].url if media.images else None
items = [
_get_track_item_payload(playlist_track.track)
for playlist_track in media.tracks.items
]
elif media_content_type == MediaType.ALBUM: elif media_content_type == MediaType.ALBUM:
if media := spotify.album(media_content_id): if media := await spotify.get_album(media_content_id):
items = media.get("tracks", {}).get("items", []) title = media.name
image = media.images[0].url if media.images else None
items = [
_get_track_item_payload(track, show_thumbnails=False)
for track in media.tracks
]
elif media_content_type == MediaType.ARTIST: elif media_content_type == MediaType.ARTIST:
if (media := spotify.artist_albums(media_content_id, limit=BROWSE_LIMIT)) and ( if (media := await spotify.get_artist_albums(media_content_id)) and (
artist := spotify.artist(media_content_id) artist := await spotify.get_artist(media_content_id)
): ):
title = artist.get("name") title = artist.name
image = fetch_image_url(artist) image = artist.images[0].url if artist.images else None
items = media.get("items", []) items = [_get_album_item_payload(album) for album in media]
elif media_content_type == MEDIA_TYPE_SHOW: elif media_content_type == MEDIA_TYPE_SHOW:
if (media := spotify.show_episodes(media_content_id, limit=BROWSE_LIMIT)) and ( if (media := await spotify.get_show_episodes(media_content_id)) and (
show := spotify.show(media_content_id) show := await spotify.get_show(media_content_id)
): ):
title = show.get("name") title = show.name
image = fetch_image_url(show) image = show.images[0].url if show.images else None
items = media.get("items", []) items = [
{
if media is None: "id": episode.episode_id,
return None "name": episode.name,
"type": MediaType.EPISODE,
"uri": episode.uri,
"thumbnail": fetch_image_url(episode.images),
}
for episode in media
]
try: try:
media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type] media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type]
@ -359,9 +439,7 @@ def build_item_response( # noqa: C901
media_item.children = [] media_item.children = []
for item in items: for item in items:
try: if (item_id := item["id"]) is None:
item_id = item["id"]
except KeyError:
_LOGGER.debug("Missing ID for media item: %s", item) _LOGGER.debug("Missing ID for media item: %s", item)
continue continue
media_item.children.append( media_item.children.append(
@ -372,21 +450,21 @@ def build_item_response( # noqa: C901
media_class=MediaClass.PLAYLIST, media_class=MediaClass.PLAYLIST,
media_content_id=item_id, media_content_id=item_id,
media_content_type=f"{MEDIA_PLAYER_PREFIX}category_playlists", media_content_type=f"{MEDIA_PLAYER_PREFIX}category_playlists",
thumbnail=fetch_image_url(item, key="icons"), thumbnail=item["thumbnail"],
title=item.get("name"), title=item["name"],
) )
) )
return media_item return media_item
if title is None: if title is None:
title = LIBRARY_MAP.get(media_content_id, "Unknown") title = LIBRARY_MAP.get(media_content_id, "Unknown")
if "name" in media:
title = media["name"]
can_play = media_content_type in PLAYABLE_MEDIA_TYPES and ( can_play = media_content_type in PLAYABLE_MEDIA_TYPES and (
media_content_type != MediaType.ARTIST or can_play_artist media_content_type != MediaType.ARTIST or can_play_artist
) )
if TYPE_CHECKING:
assert title
browse_media = BrowseMedia( browse_media = BrowseMedia(
can_expand=True, can_expand=True,
can_play=can_play, can_play=can_play,
@ -407,23 +485,16 @@ def build_item_response( # noqa: C901
except (MissingMediaInformation, UnknownMediaType): except (MissingMediaInformation, UnknownMediaType):
continue continue
if "images" in media:
browse_media.thumbnail = fetch_image_url(media)
return browse_media return browse_media
def item_payload(item: dict[str, Any], *, can_play_artist: bool) -> BrowseMedia: def item_payload(item: ItemPayload, *, can_play_artist: bool) -> BrowseMedia:
"""Create response payload for a single media item. """Create response payload for a single media item.
Used by async_browse_media. Used by async_browse_media.
""" """
try:
media_type = item["type"] media_type = item["type"]
media_id = item["uri"] media_id = item["uri"]
except KeyError as err:
_LOGGER.debug("Missing type or URI for media item: %s", item)
raise MissingMediaInformation from err
try: try:
media_class = CONTENT_TYPE_MEDIA_CLASS[media_type] media_class = CONTENT_TYPE_MEDIA_CLASS[media_type]
@ -440,25 +511,19 @@ def item_payload(item: dict[str, Any], *, can_play_artist: bool) -> BrowseMedia:
media_type != MediaType.ARTIST or can_play_artist media_type != MediaType.ARTIST or can_play_artist
) )
browse_media = BrowseMedia( return BrowseMedia(
can_expand=can_expand, can_expand=can_expand,
can_play=can_play, can_play=can_play,
children_media_class=media_class["children"], children_media_class=media_class["children"],
media_class=media_class["parent"], media_class=media_class["parent"],
media_content_id=media_id, media_content_id=media_id,
media_content_type=f"{MEDIA_PLAYER_PREFIX}{media_type}", media_content_type=f"{MEDIA_PLAYER_PREFIX}{media_type}",
title=item.get("name", "Unknown"), title=item["name"],
thumbnail=item["thumbnail"],
) )
if "images" in item:
browse_media.thumbnail = fetch_image_url(item)
elif MediaType.ALBUM in item:
browse_media.thumbnail = fetch_image_url(item[MediaType.ALBUM])
return browse_media async def library_payload(*, can_play_artist: bool) -> BrowseMedia:
def library_payload(*, can_play_artist: bool) -> BrowseMedia:
"""Create response payload to describe contents of a specific library. """Create response payload to describe contents of a specific library.
Used by async_browse_media. Used by async_browse_media.
@ -474,10 +539,16 @@ def library_payload(*, can_play_artist: bool) -> BrowseMedia:
) )
browse_media.children = [] browse_media.children = []
for item in [{"name": n, "type": t} for t, n in LIBRARY_MAP.items()]: for item_type, item_name in LIBRARY_MAP.items():
browse_media.children.append( browse_media.children.append(
item_payload( item_payload(
{"name": item["name"], "type": item["type"], "uri": item["type"]}, {
"name": item_name,
"type": item_type,
"uri": item_type,
"id": None,
"thumbnail": None,
},
can_play_artist=can_play_artist, can_play_artist=can_play_artist,
) )
) )

View File

@ -6,10 +6,12 @@ from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from spotipy import Spotify from spotifyaio import SpotifyClient
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, CONF_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, SPOTIFY_SCOPES from .const import DOMAIN, SPOTIFY_SCOPES
@ -34,27 +36,24 @@ class SpotifyFlowHandler(
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for Spotify.""" """Create an entry for Spotify."""
spotify = Spotify(auth=data["token"]["access_token"]) spotify = SpotifyClient(async_get_clientsession(self.hass))
spotify.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
try: try:
current_user = await self.hass.async_add_executor_job(spotify.current_user) current_user = await spotify.get_current_user()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return self.async_abort(reason="connection_error") return self.async_abort(reason="connection_error")
name = data["id"] = current_user["id"] name = current_user.display_name
if current_user.get("display_name"): await self.async_set_unique_id(current_user.user_id)
name = current_user["display_name"]
data["name"] = name
await self.async_set_unique_id(current_user["id"])
if self.source == SOURCE_REAUTH: if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch")
return self.async_update_reload_and_abort( return self.async_update_reload_and_abort(
self._get_reauth_entry(), title=name, data=data self._get_reauth_entry(), title=name, data=data
) )
return self.async_create_entry(title=name, data=data) return self.async_create_entry(title=name, data={**data, CONF_NAME: name})
async def async_step_reauth( async def async_step_reauth(
self, entry_data: Mapping[str, Any] self, entry_data: Mapping[str, Any]

View File

@ -3,13 +3,17 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import Any
from spotipy import Spotify, SpotifyException from spotifyaio import (
PlaybackState,
Playlist,
SpotifyClient,
SpotifyConnectionError,
UserProfile,
)
from homeassistant.components.media_player import MediaType from homeassistant.components.media_player import MediaType
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@ -22,25 +26,24 @@ _LOGGER = logging.getLogger(__name__)
class SpotifyCoordinatorData: class SpotifyCoordinatorData:
"""Class to hold Spotify data.""" """Class to hold Spotify data."""
current_playback: dict[str, Any] current_playback: PlaybackState | None
position_updated_at: datetime | None position_updated_at: datetime | None
playlist: dict[str, Any] | None playlist: Playlist | None
dj_playlist: bool = False
# This is a minimal representation of the DJ playlist that Spotify now offers # 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 # The DJ is not fully integrated with the playlist API, so we need to guard
# playlist response mocked in order to maintain functionality # against trying to fetch it as a regular playlist
SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": "DJ"} SPOTIFY_DJ_PLAYLIST_URI = "spotify:playlist:37i9dQZF1EYkqdzj48dyYq"
class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
"""Class to manage fetching Spotify data.""" """Class to manage fetching Spotify data."""
current_user: dict[str, Any] current_user: UserProfile
def __init__( def __init__(self, hass: HomeAssistant, client: SpotifyClient) -> None:
self, hass: HomeAssistant, client: Spotify, session: OAuth2Session
) -> None:
"""Initialize.""" """Initialize."""
super().__init__( super().__init__(
hass, hass,
@ -49,65 +52,46 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
update_interval=timedelta(seconds=30), update_interval=timedelta(seconds=30),
) )
self.client = client self.client = client
self._playlist: dict[str, Any] | None = None self._playlist: Playlist | None = None
self.session = session
async def _async_setup(self) -> None: async def _async_setup(self) -> None:
"""Set up the coordinator.""" """Set up the coordinator."""
try: try:
self.current_user = await self.hass.async_add_executor_job(self.client.me) self.current_user = await self.client.get_current_user()
except SpotifyException as err: except SpotifyConnectionError as err:
raise UpdateFailed("Error communicating with Spotify API") from 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: async def _async_update_data(self) -> SpotifyCoordinatorData:
if not self.session.valid_token: current = await self.client.get_playback()
await self.session.async_ensure_token_valid() if not current:
await self.hass.async_add_executor_job( return SpotifyCoordinatorData(
self.client.set_auth, self.session.token["access_token"] current_playback=None, position_updated_at=None, playlist=None
) )
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 # 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 # 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 position_updated_at = dt_util.utcnow()
context = currently_playing.get("context") or {} dj_playlist = False
if (context := current.context) is not None:
# For some users in some cases, the uri is formed like if self._playlist is None or self._playlist.uri != context.uri:
# "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 self._playlist = None
if context["type"] == MediaType.PLAYLIST: if context.uri == SPOTIFY_DJ_PLAYLIST_URI:
# The Spotify API does not currently support doing a lookup for dj_playlist = True
# the DJ playlist,so just use the minimal mock playlist object elif context.context_type == MediaType.PLAYLIST:
if uri == SPOTIFY_DJ_PLAYLIST["uri"]:
self._playlist = SPOTIFY_DJ_PLAYLIST
else:
# Make sure any playlist lookups don't break the current # Make sure any playlist lookups don't break the current
# playback state update # playback state update
try: try:
self._playlist = self.client.playlist(uri) self._playlist = await self.client.get_playlist(context.uri)
except SpotifyException: except SpotifyConnectionError:
_LOGGER.debug( _LOGGER.debug(
"Unable to load spotify playlist '%s'. " "Unable to load spotify playlist '%s'. "
"Continuing without playlist data", "Continuing without playlist data",
uri, context.uri,
) )
self._playlist = None self._playlist = None
return SpotifyCoordinatorData( return SpotifyCoordinatorData(
current_playback=currently_playing, current_playback=current,
position_updated_at=position_updated_at, position_updated_at=position_updated_at,
playlist=self._playlist, playlist=self._playlist,
dj_playlist=dj_playlist,
) )

View File

@ -9,6 +9,6 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["spotipy"], "loggers": ["spotipy"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["spotipy==2.23.0"], "requirements": ["spotifyaio==0.6.0"],
"zeroconf": ["_spotify-connect._tcp.local."] "zeroconf": ["_spotify-connect._tcp.local."]
} }

View File

@ -4,12 +4,19 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import datetime as dt import datetime as dt
from datetime import timedelta
import logging import logging
from typing import Any, Concatenate from typing import TYPE_CHECKING, Any
import requests from spotifyaio import (
from spotipy import SpotifyException Device,
Episode,
Item,
ItemType,
PlaybackState,
ProductType,
RepeatMode as SpotifyRepeatMode,
Track,
)
from yarl import URL from yarl import URL
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
@ -22,9 +29,7 @@ from homeassistant.components.media_player import (
MediaType, MediaType,
RepeatMode, RepeatMode,
) )
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
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.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import (
@ -36,12 +41,9 @@ 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 .coordinator import SpotifyCoordinator from .coordinator import SpotifyCoordinator
from .util import fetch_image_url
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
SUPPORT_SPOTIFY = ( SUPPORT_SPOTIFY = (
MediaPlayerEntityFeature.BROWSE_MEDIA MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.NEXT_TRACK
@ -57,9 +59,9 @@ SUPPORT_SPOTIFY = (
) )
REPEAT_MODE_MAPPING_TO_HA = { REPEAT_MODE_MAPPING_TO_HA = {
"context": RepeatMode.ALL, SpotifyRepeatMode.CONTEXT: RepeatMode.ALL,
"off": RepeatMode.OFF, SpotifyRepeatMode.OFF: RepeatMode.OFF,
"track": RepeatMode.ONE, SpotifyRepeatMode.TRACK: RepeatMode.ONE,
} }
REPEAT_MODE_MAPPING_TO_SPOTIFY = { REPEAT_MODE_MAPPING_TO_SPOTIFY = {
@ -74,39 +76,25 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Spotify based on a config entry.""" """Set up Spotify based on a config entry."""
data = entry.runtime_data data = entry.runtime_data
assert entry.unique_id is not None
spotify = SpotifyMediaPlayer( spotify = SpotifyMediaPlayer(
data.coordinator, data.coordinator,
data.devices, data.devices,
entry.data[CONF_ID], entry.unique_id,
entry.title, entry.title,
) )
async_add_entities([spotify]) async_add_entities([spotify])
def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R]( def ensure_item[_R](
func: Callable[Concatenate[_SpotifyMediaPlayerT, _P], _R], func: Callable[[SpotifyMediaPlayer, Item], _R],
) -> Callable[Concatenate[_SpotifyMediaPlayerT, _P], _R | None]: ) -> Callable[[SpotifyMediaPlayer], _R | None]:
"""Decorate Spotify calls to handle Spotify exception. """Ensure that the currently playing item is available."""
A decorator that wraps the passed in function, catches Spotify errors, def wrapper(self: SpotifyMediaPlayer) -> _R | None:
aiohttp exceptions and handles the availability of the media player. if not self.currently_playing or not self.currently_playing.item:
"""
def wrapper(
self: _SpotifyMediaPlayerT, *args: _P.args, **kwargs: _P.kwargs
) -> _R | None:
try:
result = func(self, *args, **kwargs)
except requests.RequestException:
self._attr_available = False
return None return None
except SpotifyException as exc: return func(self, self.currently_playing.item)
self._attr_available = False
if exc.reason == "NO_ACTIVE_DEVICE":
raise HomeAssistantError("No active playback device found") from None
raise HomeAssistantError(f"Spotify error: {exc.reason}") from exc
self._attr_available = True
return result
return wrapper return wrapper
@ -122,7 +110,7 @@ class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntit
def __init__( def __init__(
self, self,
coordinator: SpotifyCoordinator, coordinator: SpotifyCoordinator,
device_coordinator: DataUpdateCoordinator[list[dict[str, Any]]], device_coordinator: DataUpdateCoordinator[list[Device]],
user_id: str, user_id: str,
name: str, name: str,
) -> None: ) -> None:
@ -135,25 +123,23 @@ class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntit
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 {coordinator.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",
) )
@property @property
def currently_playing(self) -> dict[str, Any]: def currently_playing(self) -> PlaybackState | None:
"""Return the current playback.""" """Return the current playback."""
return self.coordinator.data.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.coordinator.current_user["product"] != "premium": if self.coordinator.current_user.product != ProductType.PREMIUM:
return MediaPlayerEntityFeature(0) return MediaPlayerEntityFeature(0)
if not self.currently_playing or self.currently_playing.get("device", {}).get( if not self.currently_playing or self.currently_playing.device.is_restricted:
"is_restricted"
):
return MediaPlayerEntityFeature.SELECT_SOURCE return MediaPlayerEntityFeature.SELECT_SOURCE
return SUPPORT_SPOTIFY return SUPPORT_SPOTIFY
@ -162,7 +148,7 @@ class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntit
"""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
@ -171,41 +157,32 @@ class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntit
"""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.device.volume_percent / 100
@property @property
def media_content_id(self) -> str | None: @ensure_item
def media_content_id(self, item: Item) -> str: # noqa: PLR0206
"""Return the media URL.""" """Return the media URL."""
if not self.currently_playing: return item.uri
return None
item = self.currently_playing.get("item") or {}
return item.get("uri")
@property @property
def media_content_type(self) -> str | None: @ensure_item
def media_content_type(self, item: Item) -> str: # noqa: PLR0206
"""Return the media type.""" """Return the media type."""
if not self.currently_playing: return MediaType.PODCAST if item.type == MediaType.EPISODE else MediaType.MUSIC
return None
item = self.currently_playing.get("item") or {}
is_episode = item.get("type") == MediaType.EPISODE
return MediaType.PODCAST if is_episode else MediaType.MUSIC
@property @property
def media_duration(self) -> int | None: @ensure_item
def media_duration(self, item: Item) -> int: # noqa: PLR0206
"""Duration of current playing media in seconds.""" """Duration of current playing media in seconds."""
if self.currently_playing is None or self.currently_playing.get("item") is None: return item.duration_ms / 1000
return None
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 or self.currently_playing.progress_ms is None:
not self.currently_playing
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 @property
def media_position_updated_at(self) -> dt.datetime | None: def media_position_updated_at(self) -> dt.datetime | None:
@ -215,131 +192,125 @@ class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntit
return self.coordinator.data.position_updated_at return self.coordinator.data.position_updated_at
@property @property
def media_image_url(self) -> str | None: @ensure_item
def media_image_url(self, item: Item) -> str | None: # noqa: PLR0206
"""Return the media image URL.""" """Return the media image URL."""
if not self.currently_playing or self.currently_playing.get("item") is None: if item.type == ItemType.EPISODE:
if TYPE_CHECKING:
assert isinstance(item, Episode)
if item.images:
return item.images[0].url
if item.show and item.show.images:
return item.show.images[0].url
return None return None
if TYPE_CHECKING:
item = self.currently_playing["item"] assert isinstance(item, Track)
if item["type"] == MediaType.EPISODE: if not item.album.images:
if item["images"]:
return fetch_image_url(item)
if item["show"]["images"]:
return fetch_image_url(item["show"])
return None return None
return item.album.images[0].url
if not item["album"]["images"]:
return None
return fetch_image_url(item["album"])
@property @property
def media_title(self) -> str | None: @ensure_item
def media_title(self, item: Item) -> str: # noqa: PLR0206
"""Return the media title.""" """Return the media title."""
if not self.currently_playing: return item.name
return None
item = self.currently_playing.get("item") or {}
return item.get("name")
@property @property
def media_artist(self) -> str | None: @ensure_item
def media_artist(self, item: Item) -> str: # noqa: PLR0206
"""Return the media artist.""" """Return the media artist."""
if not self.currently_playing or self.currently_playing.get("item") is None: if item.type == ItemType.EPISODE:
return None if TYPE_CHECKING:
assert isinstance(item, Episode)
return item.show.publisher
item = self.currently_playing["item"] if TYPE_CHECKING:
if item["type"] == MediaType.EPISODE: assert isinstance(item, Track)
return item["show"]["publisher"] return ", ".join(artist.name for artist in item.artists)
return ", ".join(artist["name"] for artist in item["artists"])
@property @property
def media_album_name(self) -> str | None: @ensure_item
def media_album_name(self, item: Item) -> str: # noqa: PLR0206
"""Return the media album.""" """Return the media album."""
if not self.currently_playing or self.currently_playing.get("item") is None: if item.type == ItemType.EPISODE:
return None if TYPE_CHECKING:
assert isinstance(item, Episode)
return item.show.name
item = self.currently_playing["item"] if TYPE_CHECKING:
if item["type"] == MediaType.EPISODE: assert isinstance(item, Track)
return item["show"]["name"] return item.album.name
return item["album"]["name"]
@property @property
def media_track(self) -> int | None: @ensure_item
def media_track(self, item: Item) -> int | None: # noqa: PLR0206
"""Track number of current playing media, music track only.""" """Track number of current playing media, music track only."""
if not self.currently_playing: if item.type == ItemType.EPISODE:
return None return None
item = self.currently_playing.get("item") or {} if TYPE_CHECKING:
return item.get("track_number") assert isinstance(item, Track)
return item.track_number
@property @property
def media_playlist(self): def media_playlist(self) -> str | None:
"""Title of Playlist currently playing.""" """Title of Playlist currently playing."""
if self.coordinator.data.dj_playlist:
return "DJ"
if self.coordinator.data.playlist is None: if self.coordinator.data.playlist is None:
return None return None
return self.coordinator.data.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.device.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.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.shuffle
@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
):
return None return None
return REPEAT_MODE_MAPPING_TO_HA.get(repeat_state) return REPEAT_MODE_MAPPING_TO_HA.get(self.currently_playing.repeat_mode)
@spotify_exception_handler async def async_set_volume_level(self, volume: float) -> None:
def set_volume_level(self, volume: float) -> None:
"""Set the volume level.""" """Set the volume level."""
self.coordinator.client.volume(int(volume * 100)) await self.coordinator.client.set_volume(int(volume * 100))
@spotify_exception_handler async def async_media_play(self) -> None:
def media_play(self) -> None:
"""Start or resume playback.""" """Start or resume playback."""
self.coordinator.client.start_playback() await self.coordinator.client.start_playback()
@spotify_exception_handler async def async_media_pause(self) -> None:
def media_pause(self) -> None:
"""Pause playback.""" """Pause playback."""
self.coordinator.client.pause_playback() await self.coordinator.client.pause_playback()
@spotify_exception_handler async def async_media_previous_track(self) -> None:
def media_previous_track(self) -> None:
"""Skip to previous track.""" """Skip to previous track."""
self.coordinator.client.previous_track() await self.coordinator.client.previous_track()
@spotify_exception_handler async def async_media_next_track(self) -> None:
def media_next_track(self) -> None:
"""Skip to next track.""" """Skip to next track."""
self.coordinator.client.next_track() await self.coordinator.client.next_track()
@spotify_exception_handler async def async_media_seek(self, position: float) -> None:
def media_seek(self, position: float) -> None:
"""Send seek command.""" """Send seek command."""
self.coordinator.client.seek_track(int(position * 1000)) await self.coordinator.client.seek_track(int(position * 1000))
@spotify_exception_handler async def async_play_media(
def play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None: ) -> None:
"""Play media.""" """Play media."""
@ -363,12 +334,8 @@ class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntit
_LOGGER.error("Media type %s is not supported", media_type) _LOGGER.error("Media type %s is not supported", media_type)
return return
if ( if not self.currently_playing and self.devices.data:
self.currently_playing kwargs["device_id"] = self.devices.data[0].device_id
and not self.currently_playing.get("device")
and self.devices.data
):
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 {
@ -379,32 +346,29 @@ class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntit
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.coordinator.client.add_to_queue(media_id, kwargs.get("device_id")) await self.coordinator.client.add_to_queue(
return media_id, kwargs.get("device_id")
self.coordinator.client.start_playback(**kwargs)
@spotify_exception_handler
def select_source(self, source: str) -> None:
"""Select playback device."""
for device in self.devices.data:
if device["name"] == source:
self.coordinator.client.transfer_playback(
device["id"], self.state == MediaPlayerState.PLAYING
) )
return return
@spotify_exception_handler await self.coordinator.client.start_playback(**kwargs)
def set_shuffle(self, shuffle: bool) -> None:
"""Enable/Disable shuffle mode."""
self.coordinator.client.shuffle(shuffle)
@spotify_exception_handler async def async_select_source(self, source: str) -> None:
def set_repeat(self, repeat: RepeatMode) -> None: """Select playback device."""
for device in self.devices.data:
if device.name == source:
await self.coordinator.client.transfer_playback(device.device_id)
return
async def async_set_shuffle(self, shuffle: bool) -> None:
"""Enable/Disable shuffle mode."""
await self.coordinator.client.set_shuffle(state=shuffle)
async def async_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.coordinator.client.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat]) await self.coordinator.client.set_repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat])
async def async_browse_media( async def async_browse_media(
self, self,
@ -416,7 +380,6 @@ class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntit
return await async_browse_media_internal( return await async_browse_media_internal(
self.hass, self.hass,
self.coordinator.client, self.coordinator.client,
self.coordinator.session,
self.coordinator.current_user, self.coordinator.current_user,
media_content_type, media_content_type,
media_content_id, media_content_id,

View File

@ -1,7 +1,8 @@
"""Models for use in Spotify integration.""" """Models for use in Spotify integration."""
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
from spotifyaio import Device
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
@ -15,4 +16,4 @@ class SpotifyData:
coordinator: SpotifyCoordinator coordinator: SpotifyCoordinator
session: OAuth2Session session: OAuth2Session
devices: DataUpdateCoordinator[list[dict[str, Any]]] devices: DataUpdateCoordinator[list[Device]]

View File

@ -2,8 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from spotifyaio import Image
import yarl import yarl
from .const import MEDIA_PLAYER_PREFIX from .const import MEDIA_PLAYER_PREFIX
@ -19,12 +18,11 @@ def resolve_spotify_media_type(media_content_type: str) -> str:
return media_content_type.removeprefix(MEDIA_PLAYER_PREFIX) return media_content_type.removeprefix(MEDIA_PLAYER_PREFIX)
def fetch_image_url(item: dict[str, Any], key="images") -> str | None: def fetch_image_url(images: list[Image]) -> str | None:
"""Fetch image url.""" """Fetch image url."""
source = item.get(key, []) if not images:
if isinstance(source, list) and source:
return source[0].get("url")
return None return None
return images[0].url
def spotify_uri_from_media_browser_url(media_content_id: str) -> str: def spotify_uri_from_media_browser_url(media_content_id: str) -> str:

View File

@ -2700,7 +2700,7 @@ speak2mary==1.4.0
speedtest-cli==2.1.3 speedtest-cli==2.1.3
# homeassistant.components.spotify # homeassistant.components.spotify
spotipy==2.23.0 spotifyaio==0.6.0
# homeassistant.components.sql # homeassistant.components.sql
sqlparse==0.5.0 sqlparse==0.5.0

View File

@ -2146,7 +2146,7 @@ speak2mary==1.4.0
speedtest-cli==2.1.3 speedtest-cli==2.1.3
# homeassistant.components.spotify # homeassistant.components.spotify
spotipy==2.23.0 spotifyaio==0.6.0
# homeassistant.components.sql # homeassistant.components.sql
sqlparse==0.5.0 sqlparse==0.5.0

View File

@ -2,9 +2,33 @@
from collections.abc import Generator from collections.abc import Generator
import time import time
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from spotifyaio.models import (
Album,
Artist,
ArtistResponse,
CategoriesResponse,
Category,
CategoryPlaylistResponse,
Devices,
FeaturedPlaylistResponse,
NewReleasesResponse,
NewReleasesResponseInner,
PlaybackState,
PlayedTrackResponse,
Playlist,
PlaylistResponse,
SavedAlbumResponse,
SavedShowResponse,
SavedTrackResponse,
Show,
ShowEpisodesResponse,
TopArtistsResponse,
TopTracksResponse,
UserProfile,
)
from homeassistant.components.application_credentials import ( from homeassistant.components.application_credentials import (
ClientCredential, ClientCredential,
@ -14,7 +38,7 @@ 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, load_json_value_fixture from tests.common import MockConfigEntry, load_fixture
SCOPES = " ".join(SPOTIFY_SCOPES) SCOPES = " ".join(SPOTIFY_SCOPES)
@ -60,48 +84,74 @@ async def setup_credentials(hass: HomeAssistant) -> None:
@pytest.fixture @pytest.fixture
def mock_spotify() -> Generator[MagicMock]: def mock_spotify() -> Generator[AsyncMock]:
"""Mock the Spotify API.""" """Mock the Spotify API."""
with ( with (
patch( patch(
"homeassistant.components.spotify.Spotify", "homeassistant.components.spotify.SpotifyClient", autospec=True
autospec=True,
) as spotify_mock, ) as spotify_mock,
patch( patch(
"homeassistant.components.spotify.config_flow.Spotify", "homeassistant.components.spotify.config_flow.SpotifyClient",
new=spotify_mock, new=spotify_mock,
), ),
): ):
client = spotify_mock.return_value client = spotify_mock.return_value
# All these fixtures can be retrieved using the Web API client at # All these fixtures can be retrieved using the Web API client at
# https://developer.spotify.com/documentation/web-api # https://developer.spotify.com/documentation/web-api
current_user = load_json_value_fixture("current_user.json", DOMAIN) for fixture, method, obj in (
client.current_user.return_value = current_user (
client.me.return_value = current_user "current_user_playlist.json",
for fixture, method in ( "get_playlists_for_current_user",
("devices.json", "devices"), PlaylistResponse,
("current_user_playlist.json", "current_user_playlists"), ),
("playback.json", "current_playback"), ("saved_albums.json", "get_saved_albums", SavedAlbumResponse),
("followed_artists.json", "current_user_followed_artists"), ("saved_tracks.json", "get_saved_tracks", SavedTrackResponse),
("saved_albums.json", "current_user_saved_albums"), ("saved_shows.json", "get_saved_shows", SavedShowResponse),
("saved_tracks.json", "current_user_saved_tracks"), (
("saved_shows.json", "current_user_saved_shows"), "recently_played_tracks.json",
("recently_played_tracks.json", "current_user_recently_played"), "get_recently_played_tracks",
("top_artists.json", "current_user_top_artists"), PlayedTrackResponse,
("top_tracks.json", "current_user_top_tracks"), ),
("featured_playlists.json", "featured_playlists"), ("top_artists.json", "get_top_artists", TopArtistsResponse),
("categories.json", "categories"), ("top_tracks.json", "get_top_tracks", TopTracksResponse),
("category_playlists.json", "category_playlists"), ("show_episodes.json", "get_show_episodes", ShowEpisodesResponse),
("category.json", "category"), ("artist_albums.json", "get_artist_albums", NewReleasesResponseInner),
("new_releases.json", "new_releases"),
("playlist.json", "playlist"),
("album.json", "album"),
("artist.json", "artist"),
("artist_albums.json", "artist_albums"),
("show_episodes.json", "show_episodes"),
("show.json", "show"),
): ):
getattr(client, method).return_value = load_json_value_fixture( getattr(client, method).return_value = obj.from_json(
fixture, DOMAIN load_fixture(fixture, DOMAIN)
).items
for fixture, method, obj in (
(
"playback.json",
"get_playback",
PlaybackState,
),
("current_user.json", "get_current_user", UserProfile),
("category.json", "get_category", Category),
("playlist.json", "get_playlist", Playlist),
("album.json", "get_album", Album),
("artist.json", "get_artist", Artist),
("show.json", "get_show", Show),
):
getattr(client, method).return_value = obj.from_json(
load_fixture(fixture, DOMAIN)
) )
client.get_followed_artists.return_value = ArtistResponse.from_json(
load_fixture("followed_artists.json", DOMAIN)
).artists.items
client.get_featured_playlists.return_value = FeaturedPlaylistResponse.from_json(
load_fixture("featured_playlists.json", DOMAIN)
).playlists.items
client.get_categories.return_value = CategoriesResponse.from_json(
load_fixture("categories.json", DOMAIN)
).categories.items
client.get_category_playlists.return_value = CategoryPlaylistResponse.from_json(
load_fixture("category_playlists.json", DOMAIN)
).playlists.items
client.get_new_releases.return_value = NewReleasesResponse.from_json(
load_fixture("new_releases.json", DOMAIN)
).albums.items
client.get_devices.return_value = Devices.from_json(
load_fixture("devices.json", DOMAIN)
).devices
yield spotify_mock yield spotify_mock

View File

@ -5,7 +5,7 @@ from ipaddress import ip_address
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from spotipy import SpotifyException from spotifyaio import SpotifyConnectionError
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.components.spotify.const import DOMAIN from homeassistant.components.spotify.const import DOMAIN
@ -111,6 +111,7 @@ async def test_full_flow(
): ):
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
assert len(hass.config_entries.async_entries(DOMAIN)) == 1, result assert len(hass.config_entries.async_entries(DOMAIN)) == 1, result
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
@ -122,6 +123,7 @@ async def test_full_flow(
"type": "Bearer", "type": "Bearer",
"expires_in": 60, "expires_in": 60,
} }
assert result["result"].unique_id == "1112264111"
@pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("current_request_with_host")
@ -157,9 +159,7 @@ async def test_abort_if_spotify_error(
}, },
) )
mock_spotify.return_value.current_user.side_effect = SpotifyException( mock_spotify.return_value.get_current_user.side_effect = SpotifyConnectionError
400, -1, "message"
)
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
@ -200,7 +200,7 @@ async def test_reauthentication(
"https://accounts.spotify.com/api/token", "https://accounts.spotify.com/api/token",
json={ json={
"refresh_token": "new-refresh-token", "refresh_token": "new-refresh-token",
"access_token": "mew-access-token", "access_token": "new-access-token",
"type": "Bearer", "type": "Bearer",
"expires_in": 60, "expires_in": 60,
}, },
@ -213,11 +213,10 @@ async def test_reauthentication(
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful" assert result["reason"] == "reauth_successful"
mock_config_entry.data["token"].pop("expires_at") mock_config_entry.data["token"].pop("expires_at")
assert mock_config_entry.data["token"] == { assert mock_config_entry.data["token"] == {
"refresh_token": "new-refresh-token", "refresh_token": "new-refresh-token",
"access_token": "mew-access-token", "access_token": "new-access-token",
"type": "Bearer", "type": "Bearer",
"expires_in": 60, "expires_in": 60,
} }
@ -237,9 +236,6 @@ async def test_reauth_account_mismatch(
result = await mock_config_entry.start_reauth_flow(hass) result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
state = config_entry_oauth2_flow._encode_jwt( state = config_entry_oauth2_flow._encode_jwt(
@ -262,7 +258,9 @@ async def test_reauth_account_mismatch(
}, },
) )
mock_spotify.return_value.current_user.return_value["id"] = "new_user_id" mock_spotify.return_value.get_current_user.return_value.user_id = (
"different_user_id"
)
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT

View File

@ -3,7 +3,7 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from spotipy import SpotifyException from spotifyaio import SpotifyConnectionError
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -33,8 +33,8 @@ async def test_setup(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"method", "method",
[ [
"me", "get_current_user",
"devices", "get_devices",
], ],
) )
async def test_setup_with_required_calls_failing( async def test_setup_with_required_calls_failing(
@ -44,22 +44,7 @@ async def test_setup_with_required_calls_failing(
method: str, method: str,
) -> None: ) -> None:
"""Test the Spotify setup with required calls failing.""" """Test the Spotify setup with required calls failing."""
getattr(mock_spotify.return_value, method).side_effect = SpotifyException( getattr(mock_spotify.return_value, method).side_effect = SpotifyConnectionError
400, "Bad Request", "Bad Request"
)
mock_config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
@pytest.mark.usefixtures("setup_credentials")
async def test_no_current_user(
hass: HomeAssistant,
mock_spotify: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Spotify setup with required calls failing."""
mock_spotify.return_value.me.return_value = None
mock_config_entry.add_to_hass(hass) mock_config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)

View File

@ -5,7 +5,12 @@ from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from spotipy import SpotifyException from spotifyaio import (
PlaybackState,
ProductType,
RepeatMode as SpotifyRepeatMode,
SpotifyConnectionError,
)
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
@ -49,21 +54,22 @@ from . import setup_integration
from tests.common import ( from tests.common import (
MockConfigEntry, MockConfigEntry,
async_fire_time_changed, async_fire_time_changed,
load_json_value_fixture, load_fixture,
snapshot_platform, snapshot_platform,
) )
@pytest.mark.freeze_time("2023-10-21")
@pytest.mark.usefixtures("setup_credentials") @pytest.mark.usefixtures("setup_credentials")
async def test_entities( async def test_entities(
hass: HomeAssistant, hass: HomeAssistant,
mock_spotify: MagicMock, mock_spotify: MagicMock,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test the Spotify entities.""" """Test the Spotify entities."""
freezer.move_to("2023-10-21")
with patch("secrets.token_hex", return_value="mock-token"): with patch("secrets.token_hex", return_value="mock-token"):
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
@ -72,18 +78,19 @@ async def test_entities(
) )
@pytest.mark.freeze_time("2023-10-21")
@pytest.mark.usefixtures("setup_credentials") @pytest.mark.usefixtures("setup_credentials")
async def test_podcast( async def test_podcast(
hass: HomeAssistant, hass: HomeAssistant,
mock_spotify: MagicMock, mock_spotify: MagicMock,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test the Spotify entities while listening a podcast.""" """Test the Spotify entities while listening a podcast."""
mock_spotify.return_value.current_playback.return_value = load_json_value_fixture( freezer.move_to("2023-10-21")
"playback_episode.json", DOMAIN mock_spotify.return_value.get_playback.return_value = PlaybackState.from_json(
load_fixture("playback_episode.json", DOMAIN)
) )
with patch("secrets.token_hex", return_value="mock-token"): with patch("secrets.token_hex", return_value="mock-token"):
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
@ -100,7 +107,7 @@ async def test_free_account(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test the Spotify entities with a free account.""" """Test the Spotify entities with a free account."""
mock_spotify.return_value.me.return_value["product"] = "free" mock_spotify.return_value.get_current_user.return_value.product = ProductType.FREE
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
state = hass.states.get("media_player.spotify_spotify_1") state = hass.states.get("media_player.spotify_spotify_1")
assert state assert state
@ -114,9 +121,7 @@ async def test_restricted_device(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test the Spotify entities with a restricted device.""" """Test the Spotify entities with a restricted device."""
mock_spotify.return_value.current_playback.return_value["device"][ mock_spotify.return_value.get_playback.return_value.device.is_restricted = True
"is_restricted"
] = True
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
state = hass.states.get("media_player.spotify_spotify_1") state = hass.states.get("media_player.spotify_spotify_1")
assert state assert state
@ -132,7 +137,7 @@ async def test_spotify_dj_list(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test the Spotify entities with a Spotify DJ playlist.""" """Test the Spotify entities with a Spotify DJ playlist."""
mock_spotify.return_value.current_playback.return_value["context"]["uri"] = ( mock_spotify.return_value.get_playback.return_value.context.uri = (
"spotify:playlist:37i9dQZF1EYkqdzj48dyYq" "spotify:playlist:37i9dQZF1EYkqdzj48dyYq"
) )
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
@ -148,9 +153,7 @@ async def test_fetching_playlist_does_not_fail(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test failing fetching playlist does not fail update.""" """Test failing fetching playlist does not fail update."""
mock_spotify.return_value.playlist.side_effect = SpotifyException( mock_spotify.return_value.get_playlist.side_effect = SpotifyConnectionError
404, "Not Found", "msg"
)
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
state = hass.states.get("media_player.spotify_spotify_1") state = hass.states.get("media_player.spotify_spotify_1")
assert state assert state
@ -164,7 +167,7 @@ async def test_idle(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test the Spotify entities in idle state.""" """Test the Spotify entities in idle state."""
mock_spotify.return_value.current_playback.return_value = {} mock_spotify.return_value.get_playback.return_value = {}
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
state = hass.states.get("media_player.spotify_spotify_1") state = hass.states.get("media_player.spotify_spotify_1")
assert state assert state
@ -211,9 +214,9 @@ async def test_repeat_mode(
"""Test the Spotify media player repeat mode.""" """Test the Spotify media player repeat mode."""
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
for mode, spotify_mode in ( for mode, spotify_mode in (
(RepeatMode.ALL, "context"), (RepeatMode.ALL, SpotifyRepeatMode.CONTEXT),
(RepeatMode.ONE, "track"), (RepeatMode.ONE, SpotifyRepeatMode.TRACK),
(RepeatMode.OFF, "off"), (RepeatMode.OFF, SpotifyRepeatMode.OFF),
): ):
await hass.services.async_call( await hass.services.async_call(
MEDIA_PLAYER_DOMAIN, MEDIA_PLAYER_DOMAIN,
@ -221,8 +224,8 @@ async def test_repeat_mode(
{ATTR_ENTITY_ID: "media_player.spotify_spotify_1", ATTR_MEDIA_REPEAT: mode}, {ATTR_ENTITY_ID: "media_player.spotify_spotify_1", ATTR_MEDIA_REPEAT: mode},
blocking=True, blocking=True,
) )
mock_spotify.return_value.repeat.assert_called_once_with(spotify_mode) mock_spotify.return_value.set_repeat.assert_called_once_with(spotify_mode)
mock_spotify.return_value.repeat.reset_mock() mock_spotify.return_value.set_repeat.reset_mock()
@pytest.mark.usefixtures("setup_credentials") @pytest.mark.usefixtures("setup_credentials")
@ -243,8 +246,8 @@ async def test_shuffle(
}, },
blocking=True, blocking=True,
) )
mock_spotify.return_value.shuffle.assert_called_once_with(shuffle) mock_spotify.return_value.set_shuffle.assert_called_once_with(state=shuffle)
mock_spotify.return_value.shuffle.reset_mock() mock_spotify.return_value.set_shuffle.reset_mock()
@pytest.mark.usefixtures("setup_credentials") @pytest.mark.usefixtures("setup_credentials")
@ -264,7 +267,7 @@ async def test_volume_level(
}, },
blocking=True, blocking=True,
) )
mock_spotify.return_value.volume.assert_called_with(50) mock_spotify.return_value.set_volume.assert_called_with(50)
@pytest.mark.usefixtures("setup_credentials") @pytest.mark.usefixtures("setup_credentials")
@ -447,7 +450,7 @@ async def test_select_source(
blocking=True, blocking=True,
) )
mock_spotify.return_value.transfer_playback.assert_called_with( mock_spotify.return_value.transfer_playback.assert_called_with(
"21dac6b0e0a1f181870fdc9749b2656466557666", True "21dac6b0e0a1f181870fdc9749b2656466557666"
) )
@ -464,9 +467,7 @@ async def test_source_devices(
assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["DESKTOP-BKC5SIK"] assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["DESKTOP-BKC5SIK"]
mock_spotify.return_value.devices.side_effect = SpotifyException( mock_spotify.return_value.get_devices.side_effect = SpotifyConnectionError
404, "Not Found", "msg"
)
freezer.tick(timedelta(minutes=5)) freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -477,20 +478,6 @@ async def test_source_devices(
assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["DESKTOP-BKC5SIK"] assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["DESKTOP-BKC5SIK"]
@pytest.mark.usefixtures("setup_credentials")
async def test_no_source_devices(
hass: HomeAssistant,
mock_spotify: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the Spotify media player with no source devices."""
mock_spotify.return_value.devices.return_value = None
await setup_integration(hass, mock_config_entry)
state = hass.states.get("media_player.spotify_spotify_1")
assert ATTR_INPUT_SOURCE_LIST not in state.attributes
@pytest.mark.usefixtures("setup_credentials") @pytest.mark.usefixtures("setup_credentials")
async def test_paused_playback( async def test_paused_playback(
hass: HomeAssistant, hass: HomeAssistant,
@ -498,7 +485,7 @@ async def test_paused_playback(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test the Spotify media player with paused playback.""" """Test the Spotify media player with paused playback."""
mock_spotify.return_value.current_playback.return_value["is_playing"] = False mock_spotify.return_value.get_playback.return_value.is_playing = False
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
state = hass.states.get("media_player.spotify_spotify_1") state = hass.states.get("media_player.spotify_spotify_1")
assert state assert state
@ -512,9 +499,9 @@ async def test_fallback_show_image(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test the Spotify media player with a fallback image.""" """Test the Spotify media player with a fallback image."""
playback = load_json_value_fixture("playback_episode.json", DOMAIN) playback = PlaybackState.from_json(load_fixture("playback_episode.json", DOMAIN))
playback["item"]["images"] = [] playback.item.images = []
mock_spotify.return_value.current_playback.return_value = playback mock_spotify.return_value.get_playback.return_value = playback
with patch("secrets.token_hex", return_value="mock-token"): with patch("secrets.token_hex", return_value="mock-token"):
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
state = hass.states.get("media_player.spotify_spotify_1") state = hass.states.get("media_player.spotify_spotify_1")
@ -532,10 +519,10 @@ async def test_no_episode_images(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test the Spotify media player with no episode images.""" """Test the Spotify media player with no episode images."""
playback = load_json_value_fixture("playback_episode.json", DOMAIN) playback = PlaybackState.from_json(load_fixture("playback_episode.json", DOMAIN))
playback["item"]["images"] = [] playback.item.images = []
playback["item"]["show"]["images"] = [] playback.item.show.images = []
mock_spotify.return_value.current_playback.return_value = playback mock_spotify.return_value.get_playback.return_value = playback
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
state = hass.states.get("media_player.spotify_spotify_1") state = hass.states.get("media_player.spotify_spotify_1")
assert state assert state
@ -549,9 +536,7 @@ async def test_no_album_images(
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test the Spotify media player with no album images.""" """Test the Spotify media player with no album images."""
mock_spotify.return_value.current_playback.return_value["item"]["album"][ mock_spotify.return_value.get_playback.return_value.item.album.images = []
"images"
] = []
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
state = hass.states.get("media_player.spotify_spotify_1") state = hass.states.get("media_player.spotify_spotify_1")
assert state assert state