mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Migrate spotify to aiospotify (#127728)
This commit is contained in:
parent
11ac8f8006
commit
494511e099
@ -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)
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -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]
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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."]
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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]]
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user