Initial Home Assistant commit

This commit is contained in:
2026-06-05 22:34:31 -04:00
commit 6a58b10e6c
4494 changed files with 297833 additions and 0 deletions
+423
View File
@@ -0,0 +1,423 @@
"""main module for spotcast homeassistant utility"""
from __future__ import annotations
__version__ = "4.0.1"
import collections
import logging
import time
import homeassistant.core as ha_core
from homeassistant.components import websocket_api
from homeassistant.const import CONF_ENTITY_ID, CONF_OFFSET, CONF_REPEAT
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from .const import (
CONF_ACCOUNTS,
CONF_DEVICE_NAME,
CONF_FORCE_PLAYBACK,
CONF_IGNORE_FULLY_PLAYED,
CONF_RANDOM,
CONF_SHUFFLE,
CONF_SP_DC,
CONF_SP_KEY,
CONF_SPOTIFY_ACCOUNT,
CONF_SPOTIFY_ALBUM_NAME,
CONF_SPOTIFY_ARTIST_NAME,
CONF_SPOTIFY_AUDIOBOOK_NAME,
CONF_SPOTIFY_CATEGORY,
CONF_SPOTIFY_COUNTRY,
CONF_SPOTIFY_DEVICE_ID,
CONF_SPOTIFY_EPISODE_NAME,
CONF_SPOTIFY_GENRE_NAME,
CONF_SPOTIFY_LIMIT,
CONF_SPOTIFY_PLAYLIST_NAME,
CONF_SPOTIFY_SHOW_NAME,
CONF_SPOTIFY_TRACK_NAME,
CONF_SPOTIFY_URI,
CONF_START_VOL,
DOMAIN,
SCHEMA_PLAYLISTS,
SCHEMA_WS_ACCOUNTS,
SCHEMA_WS_CASTDEVICES,
SCHEMA_WS_DEVICES,
SCHEMA_WS_PLAYER,
CONF_START_POSITION,
SERVICE_START_COMMAND_SCHEMA,
SPOTCAST_CONFIG_SCHEMA,
WS_TYPE_SPOTCAST_ACCOUNTS,
WS_TYPE_SPOTCAST_CASTDEVICES,
WS_TYPE_SPOTCAST_DEVICES,
WS_TYPE_SPOTCAST_PLAYER,
WS_TYPE_SPOTCAST_PLAYLISTS,
)
from .helpers import (
add_tracks_to_queue,
async_wrap,
get_cast_devices,
get_random_playlist_from_category,
get_search_results,
get_spotify_devices,
get_spotify_install_status,
get_spotify_media_player,
is_empty_str,
is_valid_uri,
url_to_spotify_uri,
)
from .spotcast_controller import SpotcastController
CONFIG_SCHEMA = SPOTCAST_CONFIG_SCHEMA
DEBUG = True
_LOGGER = logging.getLogger(__name__)
def setup(hass: ha_core.HomeAssistant, config: collections.OrderedDict) -> bool:
"""setup method for integration with Home Assistant
Args:
hass (ha_core.HomeAssistant): the HomeAssistant object of the
server
config (collections.OrderedDict): the configuration of the
server
Returns:
bool: returns a bollean based on if the setup wroked or not
"""
# get spotify core integration status
# if return false, could indicate a bad spotify integration. Race
# condition doesn't permit us to abort setup, see #258
if not get_spotify_install_status(hass):
_LOGGER.debug(
"Spotify integration was not found, please verify integration is "
"functionnal. Could result in python error..."
)
# Setup the Spotcast service.
conf = config[DOMAIN]
sp_dc = conf[CONF_SP_DC]
sp_key = conf[CONF_SP_KEY]
accounts = conf.get(CONF_ACCOUNTS)
spotcast_controller = SpotcastController(hass, sp_dc, sp_key, accounts)
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
hass.data[DOMAIN]["controller"] = spotcast_controller
@callback
def websocket_handle_playlists(
hass: ha_core.HomeAssistant,
connection,
msg: str
):
@async_wrap
def get_playlist():
"""Handle to get playlist"""
playlist_type = msg.get("playlist_type")
country_code = msg.get("country_code")
locale = msg.get("locale", "en")
limit = msg.get("limit", 10)
account = msg.get("account", None)
_LOGGER.debug("websocket_handle_playlists msg: %s", msg)
resp = spotcast_controller.get_playlists(
account, playlist_type, country_code, locale, limit
)
connection.send_message(
websocket_api.result_message(msg["id"], resp))
hass.async_add_job(get_playlist())
@callback
def websocket_handle_devices(
hass: ha_core.HomeAssistant,
connection,
msg: str,
):
@async_wrap
def get_devices():
"""Handle to get devices. Only for default account"""
account = msg.get("account", None)
client = spotcast_controller.get_spotify_client(account)
me_resp = client._get("me") # pylint: disable=W0212
spotify_media_player = get_spotify_media_player(
hass, me_resp["id"])
resp = get_spotify_devices(spotify_media_player, hass)
connection.send_message(
websocket_api.result_message(msg["id"], resp))
hass.async_add_job(get_devices())
@callback
def websocket_handle_player(
hass: ha_core.HomeAssistant,
connection,
msg: str,
):
@async_wrap
def get_player():
"""Handle to get player"""
account = msg.get("account", None)
_LOGGER.debug("websocket_handle_player msg: %s", msg)
client = spotcast_controller.get_spotify_client(account)
resp = client._get("me/player") # pylint: disable=W0212
connection.send_message(
websocket_api.result_message(msg["id"], resp))
hass.async_add_job(get_player())
@callback
def websocket_handle_accounts(
hass: ha_core.HomeAssistant,
connection,
msg: str,
):
"""Handle to get accounts"""
_LOGGER.debug("websocket_handle_accounts msg: %s", msg)
resp = list(accounts.keys()) if accounts is not None else []
resp.append("default")
connection.send_message(websocket_api.result_message(msg["id"], resp))
@callback
def websocket_handle_castdevices(
hass: ha_core.HomeAssistant,
connection, msg: str
):
"""Handle to get cast devices for debug purposes"""
_LOGGER.debug("websocket_handle_castdevices msg: %s", msg)
known_devices = get_cast_devices(hass)
_LOGGER.debug("%s", known_devices)
resp = [
{
"uuid": str(cast_info.cast_info.uuid),
"model_name": cast_info.cast_info.model_name,
"friendly_name": cast_info.cast_info.friendly_name,
}
for cast_info in known_devices
]
connection.send_message(websocket_api.result_message(msg["id"], resp))
def start_casting(call: ha_core.ServiceCall):
"""service called."""
uri = call.data.get(CONF_SPOTIFY_URI)
category = call.data.get(CONF_SPOTIFY_CATEGORY)
country = call.data.get(CONF_SPOTIFY_COUNTRY)
limit = call.data.get(CONF_SPOTIFY_LIMIT)
artistName = call.data.get(CONF_SPOTIFY_ARTIST_NAME)
albumName = call.data.get(CONF_SPOTIFY_ALBUM_NAME)
playlistName = call.data.get(CONF_SPOTIFY_PLAYLIST_NAME)
trackName = call.data.get(CONF_SPOTIFY_TRACK_NAME)
showName = call.data.get(CONF_SPOTIFY_SHOW_NAME)
episodeName = call.data.get(CONF_SPOTIFY_EPISODE_NAME)
audiobookName = call.data.get(CONF_SPOTIFY_AUDIOBOOK_NAME)
genreName = call.data.get(CONF_SPOTIFY_GENRE_NAME)
random_song = call.data.get(CONF_RANDOM, False)
repeat = call.data.get(CONF_REPEAT, False)
shuffle = call.data.get(CONF_SHUFFLE, False)
start_volume = call.data.get(CONF_START_VOL)
spotify_device_id = call.data.get(CONF_SPOTIFY_DEVICE_ID)
position = call.data.get(CONF_OFFSET)
start_position = call.data.get(CONF_START_POSITION)
force_playback = call.data.get(CONF_FORCE_PLAYBACK)
account = call.data.get(CONF_SPOTIFY_ACCOUNT)
ignore_fully_played = call.data.get(CONF_IGNORE_FULLY_PLAYED)
device_name = call.data.get(CONF_DEVICE_NAME)
entity_id = call.data.get(CONF_ENTITY_ID)
try: # yes this is ugly, quick fix while working on V4
# if no market information try to get global setting
if is_empty_str(country):
try:
country = config[DOMAIN][CONF_SPOTIFY_COUNTRY]
except KeyError:
country = None
client = spotcast_controller.get_spotify_client(account)
# verify the uri provided and clean-up if required
if not is_empty_str(uri):
# remove ? from badly formatted URI
uri = uri.split("?")[0]
if uri.startswith("http"):
try:
u = url_to_spotify_uri(uri)
_LOGGER.debug(
"converted web URL %s to spotify URI %s", uri, u)
uri = u
except ValueError:
_LOGGER.error(
"invalid web URL provided, could not convert to spotify URI: %s", uri)
if not is_valid_uri(uri):
_LOGGER.error("Invalid URI provided, aborting casting")
return
# force first two elements of uri to lowercase
uri = uri.split(":")
uri[0] = uri[0].lower()
uri[1] = uri[1].lower()
uri = ":".join(uri)
# first, rely on spotify id given in config otherwise get one
if not spotify_device_id:
spotify_device_id = spotcast_controller.get_spotify_device_id(
account, spotify_device_id, device_name, entity_id
)
if start_position is not None:
start_position *= 1000
if (
is_empty_str(uri)
and len(
list(
filter(
lambda x: not is_empty_str(x),
[
artistName,
playlistName,
trackName,
showName,
episodeName,
audiobookName,
genreName,
category,
],
)
)
)
== 0
):
_LOGGER.debug("Transfering playback")
current_playback = client.current_playback()
if current_playback is not None:
_LOGGER.debug("Current_playback from spotify: %s",
current_playback)
force_playback = True
_LOGGER.debug("Force playback: %s", force_playback)
client.transfer_playback(
device_id=spotify_device_id, force_play=force_playback
)
elif not is_empty_str(category):
uri = get_random_playlist_from_category(
client, category, country, limit)
if uri is None:
_LOGGER.error("No playlist returned. Stop service call")
return None
spotcast_controller.play(
client,
spotify_device_id,
uri,
random_song,
position,
ignore_fully_played,
start_position,
)
else:
searchResults = []
if is_empty_str(uri):
# get uri from search request
searchResults = get_search_results(
spotify_client=client,
limit=limit,
artistName=artistName,
country=country,
albumName=albumName,
playlistName=playlistName,
trackName=trackName,
showName=showName,
episodeName=episodeName,
audiobookName=audiobookName,
genreName=genreName,
)
# play the first track
if len(searchResults) > 0:
uri = searchResults[0]["uri"]
spotcast_controller.play(
client,
spotify_device_id,
uri,
random_song,
position,
ignore_fully_played,
start_position,
)
if len(searchResults) > 1:
add_tracks_to_queue(client, searchResults[1:])
if start_volume <= 100:
_LOGGER.debug("Setting volume to %d", start_volume)
time.sleep(2)
client.volume(volume_percent=start_volume,
device_id=spotify_device_id)
if shuffle:
_LOGGER.debug("Turning shuffle on")
time.sleep(3)
client.shuffle(state=shuffle, device_id=spotify_device_id)
if repeat:
_LOGGER.debug("Turning repeat on")
time.sleep(3)
client.repeat(state=repeat, device_id=spotify_device_id)
except Exception as exc:
if DEBUG:
raise exc
raise HomeAssistantError(exc) from exc
# Register websocket and service
websocket_api.async_register_command(
hass=hass,
command_or_handler=WS_TYPE_SPOTCAST_PLAYLISTS,
handler=websocket_handle_playlists,
schema=SCHEMA_PLAYLISTS,
)
websocket_api.async_register_command(
hass=hass,
command_or_handler=WS_TYPE_SPOTCAST_DEVICES,
handler=websocket_handle_devices,
schema=SCHEMA_WS_DEVICES,
)
websocket_api.async_register_command(
hass=hass,
command_or_handler=WS_TYPE_SPOTCAST_PLAYER,
handler=websocket_handle_player,
schema=SCHEMA_WS_PLAYER,
)
websocket_api.async_register_command(
hass=hass,
command_or_handler=WS_TYPE_SPOTCAST_ACCOUNTS,
handler=websocket_handle_accounts,
schema=SCHEMA_WS_ACCOUNTS,
)
websocket_api.async_register_command(
hass=hass,
command_or_handler=WS_TYPE_SPOTCAST_CASTDEVICES,
handler=websocket_handle_castdevices,
schema=SCHEMA_WS_CASTDEVICES,
)
hass.services.register(
domain=DOMAIN,
service="start",
service_func=start_casting,
schema=SERVICE_START_COMMAND_SCHEMA,
)
return True
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import logging
import homeassistant.core as ha_core
from homeassistant.components import spotify as ha_spotify
from homeassistant.components.media_player import BrowseMedia
from pychromecast import Chromecast
_LOGGER = logging.getLogger(__name__)
async def async_get_media_browser_root_object(
hass: ha_core.HomeAssistant, cast_type: str
) -> list[BrowseMedia]:
"""Create a root object for media browsing."""
try:
result = await ha_spotify.async_browse_media(hass, None, None)
except KeyError:
_LOGGER.debug(
"failed to call spotify.async_browse_media, the Home Assistant spotify "
"integration may not be setup"
)
return []
_LOGGER.debug("async_get_media_browser_root_object return %s", result.children)
return result.children
async def async_browse_media(
hass: ha_core.HomeAssistant,
media_content_type: str,
media_content_id: str,
cast_type: str,
) -> BrowseMedia | None:
"""Browse media."""
_LOGGER.debug("async_browse_media %s, %s", media_content_type, media_content_id)
result = None
# Check if this media is handled by Spotify, if it isn't just return None.
if ha_spotify.is_spotify_media_type(media_content_type):
# Browse deeper in the tree
result = await ha_spotify.async_browse_media(
hass, media_content_type, media_content_id, can_play_artist=False
)
_LOGGER.debug("async_browse_media return: %s", result)
return result
async def async_play_media(
hass: ha_core.HomeAssistant,
cast_entity_id,
chromecast: Chromecast,
media_type: str,
media_id: str,
) -> bool:
"""Play media."""
_LOGGER.debug("async_browse_media %s, %s", media_type, media_id)
# If this is a spotify URI, forward to the the spotcast.start service, if not return
# False
if media_id and media_id.startswith("spotify:"):
# Get the spotify URI
spotify_uri = ha_spotify.spotify_uri_from_media_browser_url(media_id)
data = {"entity_id": cast_entity_id, "uri": spotify_uri}
await hass.services.async_call("spotcast", "start", data, blocking=False)
return True
return False
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import CONF_ENTITY_ID, CONF_OFFSET, CONF_REPEAT
APP_SPOTIFY = "CC32E753"
DOMAIN = "spotcast"
CONF_SPOTIFY_DEVICE_ID = "spotify_device_id"
CONF_DEVICE_NAME = "device_name"
CONF_SPOTIFY_URI = "uri"
CONF_SPOTIFY_TRACK_NAME = "track_name"
CONF_SPOTIFY_ARTIST_NAME = "artist_name"
CONF_SPOTIFY_ALBUM_NAME = "album_name"
CONF_SPOTIFY_PLAYLIST_NAME = "playlist_name"
CONF_SPOTIFY_SHOW_NAME = "show_name"
CONF_SPOTIFY_EPISODE_NAME = "episode_name"
CONF_SPOTIFY_AUDIOBOOK_NAME = "audiobook_name"
CONF_SPOTIFY_GENRE_NAME = "genre_name"
CONF_SPOTIFY_CATEGORY = "category"
CONF_SPOTIFY_COUNTRY = "country"
CONF_SPOTIFY_LIMIT = "limit"
CONF_ACCOUNTS = "accounts"
CONF_SPOTIFY_ACCOUNT = "account"
CONF_FORCE_PLAYBACK = "force_playback"
CONF_RANDOM = "random_song"
CONF_SHUFFLE = "shuffle"
CONF_START_POSITION = "start_position"
CONF_SP_DC = "sp_dc"
CONF_SP_KEY = "sp_key"
CONF_START_VOL = "start_volume"
CONF_IGNORE_FULLY_PLAYED = "ignore_fully_played"
WS_TYPE_SPOTCAST_PLAYLISTS = "spotcast/playlists"
SCHEMA_PLAYLISTS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): WS_TYPE_SPOTCAST_PLAYLISTS,
vol.Required("playlist_type"): str,
vol.Optional("limit"): int,
vol.Optional("country_code"): str,
vol.Optional("locale"): str,
vol.Optional("account"): str,
}
)
WS_TYPE_SPOTCAST_DEVICES = "spotcast/devices"
SCHEMA_WS_DEVICES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): WS_TYPE_SPOTCAST_DEVICES,
vol.Optional("account"): str,
}
)
WS_TYPE_SPOTCAST_PLAYER = "spotcast/player"
SCHEMA_WS_PLAYER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): WS_TYPE_SPOTCAST_PLAYER,
vol.Optional("account"): str,
}
)
WS_TYPE_SPOTCAST_ACCOUNTS = "spotcast/accounts"
SCHEMA_WS_ACCOUNTS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): WS_TYPE_SPOTCAST_ACCOUNTS,
}
)
WS_TYPE_SPOTCAST_CASTDEVICES = "spotcast/castdevices"
SCHEMA_WS_CASTDEVICES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): WS_TYPE_SPOTCAST_CASTDEVICES,
}
)
SERVICE_START_COMMAND_SCHEMA = vol.Schema(
{
vol.Optional(CONF_DEVICE_NAME): cv.string,
vol.Optional(CONF_SPOTIFY_DEVICE_ID): cv.string,
vol.Optional(CONF_ENTITY_ID): cv.string,
vol.Optional(CONF_SPOTIFY_URI): cv.string,
vol.Optional(CONF_SPOTIFY_TRACK_NAME): cv.string,
vol.Optional(CONF_SPOTIFY_ALBUM_NAME): cv.string,
vol.Optional(CONF_SPOTIFY_ARTIST_NAME): cv.string,
vol.Optional(CONF_SPOTIFY_PLAYLIST_NAME): cv.string,
vol.Optional(CONF_SPOTIFY_SHOW_NAME): cv.string,
vol.Optional(CONF_SPOTIFY_EPISODE_NAME): cv.string,
vol.Optional(CONF_SPOTIFY_AUDIOBOOK_NAME): cv.string,
vol.Optional(CONF_SPOTIFY_GENRE_NAME): cv.string,
vol.Optional(CONF_SPOTIFY_CATEGORY): cv.string,
vol.Optional(CONF_SPOTIFY_COUNTRY): cv.string,
vol.Optional(CONF_SPOTIFY_LIMIT, default=20): cv.positive_int,
vol.Optional(CONF_SPOTIFY_ACCOUNT): cv.string,
vol.Optional(CONF_FORCE_PLAYBACK, default=False): cv.boolean,
vol.Optional(CONF_RANDOM, default=False): cv.boolean,
vol.Optional(CONF_REPEAT, default="off"): cv.string,
vol.Optional(CONF_SHUFFLE, default=False): cv.boolean,
vol.Optional(CONF_OFFSET, default=0): cv.string,
vol.Optional(CONF_START_POSITION): cv.positive_int,
vol.Optional(CONF_START_VOL, default=101): cv.positive_int,
vol.Optional(CONF_IGNORE_FULLY_PLAYED, default=False): cv.boolean,
}
)
ACCOUNTS_SCHEMA = vol.Schema(
{
vol.Required(CONF_SP_DC): cv.string,
vol.Required(CONF_SP_KEY): cv.string,
}
)
SPOTCAST_CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_SP_DC): cv.string,
vol.Required(CONF_SP_KEY): cv.string,
vol.Optional(CONF_ACCOUNTS): cv.schema_with_slug_keys(ACCOUNTS_SCHEMA),
vol.Optional(CONF_SPOTIFY_COUNTRY): cv.string,
}
),
},
extra=vol.ALLOW_EXTRA,
)
+33
View File
@@ -0,0 +1,33 @@
"""Module for cryptographic methods and functions"""
from base64 import b32encode
from pyotp import TOTP
_CIPHER_BASE = (12, 56, 76, 33, 88, 44, 88, 33,
78, 78, 11, 66, 22, 22, 55, 69, 54)
CIPHER_BYTES = [j ^ (i % 33 + 9) for i, j in enumerate(_CIPHER_BASE)]
def hex_to_bytes(data: str) -> bytes:
"""Converts a hex string to bytes"""
data = data.replace(" ", "")
return bytes.fromhex(data)
def get_totp(
digits: int = 6,
digest: str = "sha1",
interval: int = 30
) -> TOTP:
"""Provides a time-based OTP manager compliant for spotify TOTP
scheme"""
secret_hex = ''.join(str(x) for x in CIPHER_BYTES)
secret_hex = secret_hex.encode()
secret_hex = "".join(format(x, 'x') for x in secret_hex)
secret_bytes = hex_to_bytes(secret_hex)
secret = b32encode(secret_bytes).decode().strip('=')
return TOTP(secret, digits=digits, digest=digest, interval=interval)
+5
View File
@@ -0,0 +1,5 @@
class LaunchError(Exception):
"""When an app fails to launch."""
class TokenError(Exception):
pass
+529
View File
@@ -0,0 +1,529 @@
from __future__ import annotations
import asyncio
import logging
import requests
import urllib.parse
import difflib
from urllib.parse import unquote as urldecode
import random
import time
from functools import partial, wraps
import homeassistant.core as ha_core
# import for type inference
import spotipy
from spotipy import SpotifyException
from homeassistant.components.cast.media_player import CastDevice
from homeassistant.components.spotify.media_player import SpotifyMediaPlayer
from homeassistant.exceptions import HomeAssistantError
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
_LOGGER = logging.getLogger(__name__)
def get_spotify_media_player(
hass: ha_core.HomeAssistant, spotify_user_id: str
) -> SpotifyMediaPlayer:
"""Get the spotify media player entity from hass."""
platforms = entity_platform.async_get_platforms(hass, "spotify")
spotify_media_player = None
for platform in platforms:
if platform.domain != "media_player":
continue
for entity in platform.entities.values():
if (
isinstance(entity, SpotifyMediaPlayer)
and entity.unique_id == spotify_user_id
):
try:
entity_devices = entity._devices
except (AttributeError):
try:
entity_devices = entity.data.devices.data
except AttributeError:
entity_devices = entity.devices.data
_LOGGER.debug(
f"get_spotify_devices: {entity.entity_id}: "
f"{entity.name}: %s",
entity_devices,
)
spotify_media_player = entity
break
if spotify_media_player:
return spotify_media_player
else:
raise HomeAssistantError("Could not find spotify media player.")
def get_spotify_devices(
spotify_media_player: SpotifyMediaPlayer,
hass: HomeAssistant
):
if spotify_media_player:
# Need to come from media_player spotify's sp client due to
# token issues
asyncio.run_coroutine_threadsafe(
spotify_media_player.devices.async_refresh(),
hass.loop,
).result()
spotify_devices = spotify_media_player.devices.data
return spotify_devices
return []
def get_spotify_install_status(hass):
platform_string = "spotify"
platforms = entity_platform.async_get_platforms(hass, platform_string)
platform_count = len(platforms)
if platform_count == 0:
_LOGGER.error("%s integration not found", platform_string)
else:
_LOGGER.debug("%s integration found", platform_string)
return platform_count != 0
def get_cast_devices(hass):
platforms = entity_platform.async_get_platforms(hass, "cast")
cast_infos = []
for platform in platforms:
if platform.domain != "media_player":
continue
for entity in platform.entities.values():
if isinstance(entity, CastDevice):
_LOGGER.debug(
f"get_cast_devices: {entity.entity_id}: "
f"{entity.name} cast info: % s",
entity._cast_info,
)
cast_infos.append(entity._cast_info)
return cast_infos
# Async wrap sync function
def async_wrap(func):
@wraps(func)
async def run(*args, loop=None, executor=None, **kwargs):
if loop is None:
loop = asyncio.get_event_loop()
pfunc = partial(func, *args, **kwargs)
return await loop.run_in_executor(executor, pfunc)
return run
def get_top_tracks(
artistName: str,
spotify_client: spotipy.Spotify,
limit: int = 20,
country: str = None,
):
_LOGGER.debug("Searching for top tracks for the artist: %s", artistName)
searchType = "artist"
search = searchType + ":" + artistName
artistUri = ""
# get artist uri
try:
artist = spotify_client.search(
q=search,
limit=1,
offset=0,
type="artist",
market=country,
)["artists"]["items"][0]
_LOGGER.debug("found artist %s: %s", artist["name"], artist["uri"])
artistUri = artist["uri"]
except IndexError:
pass
results = spotify_client.artist_top_tracks(artistUri)
for track in results["tracks"][:10]:
_LOGGER.debug("track : " + track["name"])
return results["tracks"]
def get_search_string(
artistName: str,
albumName: str,
trackName: str,
genreName: str,
playlistName: str,
showName: str,
episodeName: str,
audiobookName: str,
) -> str:
search = []
if not is_empty_str(artistName):
search.append(f"artist:{artistName}")
search.append(artistName)
if not is_empty_str(albumName):
search.append(f"album:{albumName}")
search.append(albumName)
if not is_empty_str(trackName):
search.append(f"track:{trackName}")
search.append(trackName)
if not is_empty_str(genreName):
search.append(f"genre:{genreName}")
search.append(genreName)
# if we are searching for a playlist, podcast, audiobook, we need
# some search query which is probably just the text we are looking
# for
for item in [playlistName, showName, episodeName, audiobookName]:
if not is_empty_str(item):
search.append(item)
return " ".join(search)
# "album", "artist", "playlist", "track", "show", "episode", "audiobook"
def get_types_string(
artistName: str,
albumName: str,
trackName: str,
playlistName: str,
showName: str,
episodeName: str,
audiobookName: str,
) -> str:
types = []
if not is_empty_str(artistName):
types.append("artist")
if not is_empty_str(albumName):
types.append("album")
if not is_empty_str(trackName):
types.append("track")
if not is_empty_str(playlistName):
types.append("playlist")
if not is_empty_str(showName):
types.append("show")
if not is_empty_str(episodeName):
types.append("episode")
if not is_empty_str(audiobookName):
types.append("audiobook")
return ",".join(types)
def get_search_results(
spotify_client: spotipy.Spotify,
limit: int = 10,
country: str = None,
artistName: str = None,
albumName: str = None,
playlistName: str = None,
trackName: str = None,
showName: str = None,
episodeName: str = None,
audiobookName: str = None,
genreName: str = None,
):
_LOGGER.debug("using search query to find uri")
searchResults = []
if (
not is_empty_str(artistName)
and len(
list(
filter(
lambda x: not is_empty_str(x),
[
albumName,
playlistName,
trackName,
showName,
episodeName,
audiobookName,
genreName,
],
)
)
)
== 0
):
searchResults = get_top_tracks(artistName, spotify_client)
_LOGGER.debug("Playing top tracks for artist: %s",
searchResults[0]["name"])
return searchResults
else:
searchString = get_search_string(
artistName=artistName,
albumName=albumName,
trackName=trackName,
genreName=genreName,
playlistName=playlistName,
showName=showName,
episodeName=episodeName,
audiobookName=audiobookName,
)
searchTypes = get_types_string(
artistName=artistName,
albumName=albumName,
trackName=trackName,
playlistName=playlistName,
showName=showName,
episodeName=episodeName,
audiobookName=audiobookName,
)
searchResults = spotify_client.search(
q=searchString,
limit=limit,
offset=0,
type=searchTypes,
market=country
)
compiledResults = []
if "tracks" in searchResults:
for item in searchResults["tracks"]["items"]:
compiledResults.append(item)
if "albums" in searchResults:
for item in searchResults["albums"]["items"]:
compiledResults.append(item)
if "playlists" in searchResults:
for item in searchResults["playlists"]["items"]:
compiledResults.append(item)
if "shows" in searchResults:
for item in searchResults["shows"]["items"]:
compiledResults.append(item)
if "audiobooks" in searchResults:
for item in searchResults["audiobooks"]["items"]:
compiledResults.append(item)
if "episodes" in searchResults:
for item in searchResults["episodes"]["items"]:
compiledResults.append(item)
_LOGGER.debug(
"Found %d results for %s. First Track name: %s",
len(compiledResults),
searchString,
compiledResults[0]["name"],
)
return compiledResults
def search_tracks(
search: str,
spotify_client: spotipy.Spotify,
appendToQueue: bool = False,
shuffle: bool = False,
startRandom: bool = False,
limit: int = 20,
artistName: str = None,
country: str = None,
):
results = get_search_results(
search, spotify_client, artistName, limit, country)
if len(results) > 0:
firstResult = [results[0]]
if not startRandom:
results = results[1:limit]
if shuffle:
random.shuffle(results)
if not startRandom:
results = firstResult + results
return results
def add_tracks_to_queue(
spotify_client: spotipy.Spotify, tracks: list = [], limit: int = 20
):
filtered = list(filter(lambda x: isinstance(x, dict)
and x.get("type") == "track", tracks))
if len(filtered) == 0:
_LOGGER.debug("Cannot add ZERO tracks to the queue!")
return
for track in filtered[:limit]:
_LOGGER.debug(
"Adding " + track["name"] +
" to the playback queue | " + track["uri"]
)
max_attemps = 5
backoff_rate = 1.2
delay = 1
current_attempt = 0
while True:
try:
spotify_client.add_to_queue(track["uri"])
except SpotifyException as exc:
if current_attempt >= max_attemps:
raise HomeAssistantError(
"Coulddn't addd song to queue"
) from exc
_LOGGER.warning("Couldn't add song to queue retrying")
time.sleep(delay)
current_attempt += 1
delay *= backoff_rate
continue
break
time.sleep(0.5)
def get_random_playlist_from_category(
spotify_client: spotipy.Spotify,
category: str,
country: str = None,
limit: int = 20,
) -> str:
if country is None:
_LOGGER.debug(
f"Get random playlist among {limit} playlists from category "
f"{category}, no country specified."
)
else:
_LOGGER.debug(
f"Get random playlist among {limit} playlists from category "
f"{category} in country {country}"
)
# validate category and country are valid entries
if country.upper() not in spotify_client.country_codes:
_LOGGER.error(f"{country} is not a valid country code")
return None
# get list of playlist from category and localisation provided
try:
playlists = spotify_client.category_playlists(
category_id=category, country=country, limit=limit
)["playlists"]["items"]
except spotipy.exceptions.SpotifyException as e:
_LOGGER.error(e.msg)
return None
# choose one at random
chosen = random.choice(playlists)
_LOGGER.debug(
f"Chose playlist {chosen['name']}({chosen['uri']}) from category "
f"{category}."
)
return chosen["uri"]
def url_to_spotify_uri(url: str) -> str:
"""
Convert a spotify web url (e.g. https://open.spotify.com/track/XXXX) to
a spotify-style URI (spotify:track:XXXX). Returns None on error.
"""
o: urllib.parse.ParseResult
# will raise ValueError if URL is invalid
o = urllib.parse.urlparse(url)
if o.hostname != "open.spotify.com":
raise ValueError(
'Spotify URLs must have a hostname of "open.spotify.com"')
path = o.path.split("/")
if len(path) != 3:
raise ValueError(
'Spotify URLs must be of the form "https://open.spotify.com/<kind>/<target>"')
return f'spotify:{path[1]}:{path[2]}'
def is_valid_uri(uri: str) -> bool:
# list of possible types
types = ["artist", "album", "track", "playlist", "show", "episode"]
# split the string
elems = uri.split(":")
# validate number of sub elements
if elems[1].lower() == "user":
elems = elems[0:1] + elems[3:]
types = ["playlist"]
_LOGGER.debug(
"Excluding user information from the Spotify URI validation. Only"
" supported for playlists"
)
# support playing a user's liked songs list
# (spotify:user:username:collection)
if len(elems) == 2 and elems[1].lower() == "collection":
return True
if len(elems) != 3:
_LOGGER.error(
f"[{uri}] is not a valid URI. The format should be "
"[spotify:<type>:<unique_id>]"
)
return False
# check correct format of the sub elements
if elems[0].lower() != "spotify":
_LOGGER.error(
f"This is not a valid Spotify URI. This should start with "
f"[spotify], but instead starts with [{elems[0]}]"
)
return False
if elems[1].lower() not in types:
_LOGGER.error(
f"{elems[1]} is not a valid type for Spotify request. Please "
f"make sure to use the following list {str(types)}"
)
return False
if "?" in elems[2]:
_LOGGER.warning(
f"{elems[2]} contains query character. This should work, but you"
" should probably remove it and anything after."
)
# return True if all test passes
return True
def is_empty_str(string: str) -> bool:
return string is None or string.strip() == ""
def query_from_url(url: str) -> dict[str, str]:
"""Extracts the query part from a url"""
if url is None or url == "":
return {}
query = url.split('?', maxsplit=1)[-1]
query = dict([x.split('=') for x in query.split('&')])
query = {urldecode(x): urldecode(y) for x, y in query.items()}
return query
+22
View File
@@ -0,0 +1,22 @@
{
"domain": "spotcast",
"name": "Spotcast",
"after_dependencies": [
"cast",
"spotify"
],
"codeowners": [
"@fondberg",
"@fcusson"
],
"dependencies": [
"spotify"
],
"documentation": "https://github.com/fondberg/spotcast",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/fondberg/spotcast/issues",
"requirements": [
"spotipy==2.23.0"
],
"version": "v4.0.1"
}
+126
View File
@@ -0,0 +1,126 @@
"""Sensor platform for Chromecast devices."""
from __future__ import annotations
import collections
import json
import logging
from datetime import timedelta
import homeassistant.core as ha_core
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import STATE_OK, STATE_UNKNOWN
from homeassistant.util import dt
from .const import CONF_SPOTIFY_COUNTRY, DOMAIN
from .helpers import get_cast_devices
_LOGGER = logging.getLogger(__name__)
SENSOR_SCAN_INTERVAL_SECS = 60
SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS)
def setup_platform(
hass: ha_core.HomeAssistant,
config: collections.OrderedDict,
add_devices,
discovery_info=None,
):
try:
country = config[CONF_SPOTIFY_COUNTRY]
except KeyError:
country = None
add_devices([ChromecastDevicesSensor(hass)])
add_devices([ChromecastPlaylistSensor(hass, country)])
class ChromecastDevicesSensor(SensorEntity):
def __init__(self, hass):
self.hass = hass
self._state = STATE_UNKNOWN
self._chromecast_devices = []
self._attributes = {"devices_json": [], "devices": [], "last_update": None}
_LOGGER.debug("initiating sensor")
@property
def name(self):
return "Chromecast Devices"
@property
def state(self):
return self._state
@property
def extra_state_attributes(self):
"""Return the state attributes."""
return self._attributes
def update(self):
_LOGGER.debug("Getting chromecast devices")
known_devices = get_cast_devices(self.hass)
_LOGGER.debug("sensor devices %s", known_devices)
chromecasts = [
{
"uuid": str(cast_info.cast_info.uuid),
"model_name": cast_info.cast_info.model_name,
"name": cast_info.cast_info.friendly_name,
"manufacturer": cast_info.cast_info.manufacturer,
"cast_type": cast_info.cast_info.cast_type,
}
for cast_info in known_devices
]
self._attributes["devices_json"] = json.dumps(chromecasts, ensure_ascii=False)
self._attributes["devices"] = chromecasts
self._attributes["last_update"] = dt.now().isoformat("T")
self._state = STATE_OK
class ChromecastPlaylistSensor(SensorEntity):
def __init__(self, hass: ha_core, country=None):
self.hass = hass
self._state = STATE_UNKNOWN
self.country = country
self._attributes = {"playlists": [], "last_update": None}
_LOGGER.debug("initiating playlist sensor")
@property
def name(self):
return "Playlists sensor"
@property
def state(self):
return self._state
@property
def extra_state_attributes(self):
"""Return the state attributes."""
return self._attributes
def update(self):
_LOGGER.debug("Getting playlists")
if self.country is not None:
country_code = self.country
else:
# kept the country code to SE if not provided by the user for retrocompatibility
country_code = "SE"
playlist_type = "user"
locale = "en"
limit = 10
account = None
resp = self.hass.data[DOMAIN]["controller"].get_playlists(
account, playlist_type, country_code, locale, limit
)
self._attributes["playlists"] = [
{"uri": x["uri"], "name": x["name"]} for x in resp["items"]
]
self._attributes["last_update"] = dt.now().isoformat("T")
self._state = STATE_OK
+200
View File
@@ -0,0 +1,200 @@
start:
name: Start Spotcast
description: Starts spotify playback on chromecast devices
fields:
device_name:
name: "Device Name"
description: "The friendly name of the chromecast or spotify connect device. First checks spotify device list for name (not used together with entity_id and spotify_device_id)."
example: "Livingroom"
required: false
selector:
text:
spotify_device_id:
name: "Spotify Device ID"
description: "Advanced users only. The spotify device id (not used together with entity_id or device_name)."
example: "4363634563457346xcyvydgf3qwa"
required: false
selector:
text:
entity_id:
name: "Entity ID"
description: "The entity_id of the chromecast mediaplayer. Friendly name MUST match the spotify connect device name (not used together with device_name and spotify_device_id)."
example: "media_player.vardagsrum"
required: false
selector:
entity:
domain: media_player
integration: cast
uri:
name: "URI"
description: "Supported Spotify URI as string. None or empty uri will transfer the current/last playback (see parameter force_playback)."
example: "spotify:playlist:37i9dQZF1DX3yvAYDslnv8"
required: false
selector:
text:
category:
name: "Category"
description: "A category to fetch playlist from. See https://developer.spotify.com/console/get-browse-categories/ for a list of categories"
required: false
selector:
text:
country:
name: "Country"
description: "Country code to use with category. See https://spotipy.readthedocs.io/en/2.19.0/#spotipy.client.Spotify.country_codes for list of available codes"
required: false
selector:
text:
limit:
name: "Limit"
description: "Limit of playlist to fetch in a given category. Default 20"
required: false
default: 20
selector:
number:
mode: box
step: 1
min: 0
max: 50
album_name:
name: "Album Name"
example: "The Dark Side of the Moon"
description: "Filters search results for the provided album name. Don't include this if you don't want an album."
required: false
selector:
text:
track_name:
name: "Track Name"
example: "Money"
description: "Filters search results for the provided track name. Don't include this if you don't want a particular track."
required: false
selector:
text:
playlist_name:
name: "Playlist Name"
example: "Ultimate pink floyd playlist"
description: "Filters search results for the provided playlist name. Don't include this if you don't want a playlist."
required: false
selector:
text:
show_name:
name: "Show Name"
example: "Hollywood Handbook"
description: "Filters search results for the provided podcast show name. Don't include this if you don't want a podcast."
required: false
selector:
text:
episode_name:
name: "Episode Name"
example: "Sarah Sherman, Our Close Friend"
description: "Filters search results for the provided podcast episode name. Don't include this if you don't want a podcast."
required: false
selector:
text:
genre_name:
name: "Genre Name"
example: "post punk"
description: "Filters search results by genre of music"
required: false
selector:
text:
audiobook_name:
name: "Audiobook Name"
example: "Ulysses"
description: "Filters search results for the provided audiobook name. Don't include this if you don't want an audiobook."
required: false
selector:
text:
artist_name:
name: "Artist Name"
example: "pink floyd"
description: "This will filter search results to match the provided artist name. Don't include this if searching for a playlist or genre. Do include the author's name if searching for audiobooks."
required: false
selector:
text:
account:
name: "Account"
description: "Optionally starts Spotify using an alternative account specified in config."
example: "my_wifes"
required: false
selector:
text:
force_playback:
name: "Force Playback"
description: "In case of transfering playback: If true starts playing the user's last playback even if nothing is currently playing."
example: true
required: false
default: false
selector:
boolean:
random_song:
name: "Random Song"
description: "Starts the playback at a random position in the playlist or album."
example: true
required: false
default: false
selector:
boolean:
repeat:
name: "Repeat"
description: "Set repeat mode for playback."
example: "track"
required: false
default: "off"
selector:
select:
options:
- "track"
- "context"
- "off"
shuffle:
name: "Shuffle"
description: "Set shuffle mode for playback."
example: true
required: false
default: false
selector:
boolean:
offset:
name: "Offset"
description: "Set offset mode for playback. 0 is the first song."
example: 1
required: false
default: 0
selector:
number:
mode: box
step: 1
min: 0
max: 999999
start_position:
name: "Position"
description: "Start position of the track in seconds"
example: 1
required: false
default: 0
selector:
number:
mode: box
step: 1
min: 0
max: 999999
start_volume:
name: "Start Volume"
description: "Set the volume for playback in percentage."
example: 50
required: false
selector:
number:
mode: slider
step: 1
min: 0
max: 100
ignore_fully_played:
name: "Ignore Fully Played"
description: "Set to ignore or not already played episodes in a podcast playlist"
example: true
required: false
default: false
selector:
boolean:
@@ -0,0 +1,483 @@
from __future__ import annotations
import collections
import json
import logging
import random
import time
from asyncio import run_coroutine_threadsafe
from collections import OrderedDict
from datetime import datetime
import aiohttp
import homeassistant.core as ha_core
import pychromecast
import spotipy
from homeassistant.components.cast.helpers import ChromeCastZeroconf
from homeassistant.exceptions import HomeAssistantError
from requests import TooManyRedirects
from .spotify_controller import SpotifyController
from .error import TokenError
from .const import CONF_SP_DC, CONF_SP_KEY
from .helpers import get_cast_devices, get_spotify_devices, get_spotify_media_player, query_from_url
from .spotify_controller import SpotifyController
from .crypto import get_totp
_LOGGER = logging.getLogger(__name__)
class SpotifyCastDevice:
"""Represents a spotify device."""
hass = None
castDevice = None
spotifyController = None
def __init__(
self, hass: ha_core.HomeAssistant, call_device_name: str, call_entity_id: str
) -> None:
"""Initialize a spotify cast device."""
self.hass = hass
# Get device name from either device_name or entity_id
device_name = None
if call_device_name is None:
entity_id = call_entity_id
if entity_id is None:
raise HomeAssistantError(
"Either entity_id or device_name must be specified"
)
entity_states = hass.states.get(entity_id)
if entity_states is None:
_LOGGER.error("Could not find entity_id: %s", entity_id)
else:
device_name = entity_states.attributes.get("friendly_name")
else:
device_name = call_device_name
if device_name is None or device_name.strip() == "":
raise HomeAssistantError("device_name is empty")
# Find chromecast device
self.castDevice = self.get_chromecast_device(device_name)
_LOGGER.debug("Found cast device: %s", self.castDevice)
self.castDevice.wait()
def get_chromecast_device(self, device_name: str) -> None:
# Get cast from discovered devices of cast platform
known_devices = get_cast_devices(self.hass)
_LOGGER.debug("Chromecast devices: %s", known_devices)
cast_info = next(
(
castinfo
for castinfo in known_devices
if castinfo.friendly_name == device_name
),
None,
)
_LOGGER.debug("cast info: %s", cast_info)
if cast_info:
return pychromecast.get_chromecast_from_cast_info(
cast_info.cast_info, ChromeCastZeroconf.get_zeroconf()
)
_LOGGER.error(
"Could not find device %s from hass.data",
device_name,
)
raise HomeAssistantError(
"Could not find device with name {}".format(device_name)
)
def start_spotify_controller(self, access_token: str, expires: int):
sp = SpotifyController(self.castDevice, access_token, expires)
self.castDevice.register_handler(sp)
sp.launch_app()
if not sp.is_launched and not sp.credential_error:
raise HomeAssistantError(
"Failed to launch spotify controller due to timeout"
)
if not sp.is_launched and sp.credential_error:
raise HomeAssistantError(
"Failed to launch spotify controller due to credentials error"
)
self.spotifyController = sp
def get_spotify_device_id(self, user_id) -> None:
spotify_media_player = get_spotify_media_player(self.hass, user_id)
max_retries = 5
counter = 0
devices_available = None
_LOGGER.debug(
"Searching for Spotify device: {}".format(
self.spotifyController.device)
)
while counter < max_retries:
devices_available = get_spotify_devices(
spotify_media_player,
self.hass
)
# Look for device to make sure we can start playback
for device in devices_available:
if device.device_id == self.spotifyController.device:
_LOGGER.debug(
"Found matching Spotify device: {}".format(device)
)
return device.device_id
sleep = random.uniform(1.5, 1.8) ** counter
time.sleep(sleep)
counter = counter + 1
_LOGGER.error(
'No device with id "{}" known by Spotify'.format(
self.spotifyController.device
)
)
_LOGGER.error("Known devices: {}".format(devices_available))
raise HomeAssistantError("Failed to get device id from Spotify")
class SpotifyToken:
"""Represents a spotify token for an account."""
hass = None
sp_dc = None
sp_key = None
_access_token = None
_token_expires = 0
def __init__(self, hass: ha_core.HomeAssistant, sp_dc: str, sp_key: str):
self.hass = hass
self.sp_dc = sp_dc
self.sp_key = sp_key
self.totp = get_totp()
def ensure_token_valid(self) -> bool:
if float(self._token_expires) > time.time():
return True
self.get_spotify_token()
@property
def access_token(self) -> str:
self.ensure_token_valid()
_LOGGER.debug("expires: %s time: %s", self._token_expires, time.time())
return self._access_token
def get_spotify_token(self) -> tuple[str, int]:
try:
self._access_token, self._token_expires = run_coroutine_threadsafe(
self.start_session(), self.hass.loop
).result()
expires = self._token_expires - int(time.time())
return self._access_token, expires
except TooManyRedirects:
_LOGGER.error(
"Could not get spotify token. sp_dc and sp_key could be "
"expired. Please update in config."
)
raise HomeAssistantError("Expired sp_dc, sp_key")
except (TokenError, Exception) as exc: # noqa: E722
raise HomeAssistantError(exc)
@property
def headers(self) -> dict:
"""Provides the generic headers for api requests"""
return {
"user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 "
"Safari/537.36"
),
"Accept": "application/json",
}
async def start_session(self, max_retries=5):
""" Starts session to get access token. """
cookies = {"sp_dc": self.sp_dc, "sp_key": self.sp_key}
async with aiohttp.ClientSession(cookies=cookies) as session:
# get server time
async with session.get(
url="https://open.spotify.com/server-time",
headers=self.headers,
) as response:
data = await response.json()
server_time = data["serverTime"]
totp_value = self.totp.at(server_time)
retry_count = 0
while True:
async with session.get(
url="https://open.spotify.com/get_access_token",
allow_redirects=False,
headers=self.headers,
params={
"reason": "transport",
"productType": "web-player",
"totp": totp_value,
"totpServer": totp_value,
"totpVer": 5,
"sTime": server_time,
"cTime": server_time,
}
) as response:
data = await response.text()
headers = response.headers
status = response.status
try:
self.raise_for_status(status, data, headers)
data = json.loads(data)
await self._test_token(session, data["accessToken"])
break
except (HomeAssistantError, TokenError) as exc:
if retry_count >= max_retries - 1:
raise exc
retry_count += 1
access_token = data["accessToken"]
expires_timestamp = data["accessTokenExpirationTimestampMs"]
expiration_date = int(expires_timestamp) // 1000
return access_token, expiration_date
def raise_for_status(self, status: int, content: str, headers: dict):
"""Raises an error for invalid response"""
location_query = query_from_url(headers.get("Location"))
if status == 302 and location_query.get("_authfailed", "0") == "1":
_LOGGER.error(
"Unsuccessful token request, received code 302 and "
"Location header %s. sp_dc and sp_key could be "
"expired. Please update in config.",
headers["Location"],
)
raise HomeAssistantError("Expired sp_dc, sp_key")
if status != 200:
_LOGGER.info(
"Unsuccessful token request, received code %i", status
)
raise TokenError()
async def _test_token(self, session: aiohttp.ClientSession, token: str):
"""Test the token in the session provided"""
headers = self.headers
headers |= {"Authorization": f"Bearer {token}"}
async with session.get(
url="https://api.spotify.com/v1/me",
headers=headers
) as response:
await response.json()
if not response.ok:
_LOGGER.debug("Token received is not valid. Retrying")
raise TokenError("Token received is not valid. Retrying")
class SpotcastController:
spotifyTokenInstances = {}
accounts: dict = {}
hass = None
def __init__(
self,
hass: ha_core.HomeAssistant,
sp_dc: str,
sp_key: str,
accs: collections.OrderedDict,
) -> None:
if accs:
self.accounts = accs
self.accounts["default"] = OrderedDict(
[("sp_dc", sp_dc), ("sp_key", sp_key)])
self.hass = hass
def get_token_instance(self, account: str = None) -> any:
"""Get token instance for account"""
if account is None:
account = "default"
# TODO: add error logging when user provide invalid account
# name
dc = self.accounts.get(account).get(CONF_SP_DC)
key = self.accounts.get(account).get(CONF_SP_KEY)
_LOGGER.debug("setting up with account %s", account)
if account not in self.spotifyTokenInstances:
self.spotifyTokenInstances[account] = SpotifyToken(
self.hass, dc, key)
return self.spotifyTokenInstances[account]
def get_spotify_client(self, account: str) -> spotipy.Spotify:
return spotipy.Spotify(auth=self.get_token_instance(account).access_token)
def _getSpotifyConnectDeviceId(self, client, device_name):
media_player = get_spotify_media_player(
self.hass, client._get("me")["id"])
devices_available = get_spotify_devices(media_player, self.hass)
for device in devices_available:
if device.name == device_name:
return device.device_id
return None
def get_spotify_device_id(self, account, spotify_device_id, device_name, entity_id):
# login as real browser to get powerful token
access_token, expires = self.get_token_instance(
account).get_spotify_token()
# get the spotify web api client
client = spotipy.Spotify(auth=access_token)
# first, rely on spotify id given in config
if not spotify_device_id:
# if not present, check if there's a spotify connect device
# with that name
spotify_device_id = self._getSpotifyConnectDeviceId(
client, device_name)
if not spotify_device_id:
# if still no id available, check cast devices and launch
# the app on chromecast
spotify_cast_device = SpotifyCastDevice(
self.hass,
device_name,
entity_id,
)
me_resp = client._get("me")
spotify_cast_device.start_spotify_controller(access_token, expires)
# Make sure it is started
spotify_device_id = spotify_cast_device.get_spotify_device_id(
me_resp["id"])
return spotify_device_id
def play(
self,
client: spotipy.Spotify,
spotify_device_id: str,
uri: str,
random_song: bool,
position: str,
ignore_fully_played: str,
position_ms: str,
country_code: str = None
) -> None:
_LOGGER.debug(
"Playing URI: %s on device-id: %s",
uri,
spotify_device_id,
)
if uri.find("show") > 0:
show_episodes_info = client.show_episodes(uri, market=country_code)
if show_episodes_info and len(show_episodes_info["items"]) > 0:
if ignore_fully_played:
for episode in show_episodes_info["items"]:
if not episode["resume_point"]["fully_played"]:
episode_uri = episode["external_urls"]["spotify"]
break
else:
episode_uri = show_episodes_info["items"][0]["external_urls"][
"spotify"
]
_LOGGER.debug(
(
"Playing episode using uris (latest podcast playlist)="
" for uri: %s"
),
episode_uri,
)
client.start_playback(
device_id=spotify_device_id, uris=[episode_uri], position_ms=position_ms)
elif uri.find("episode") > 0:
_LOGGER.debug("Playing episode using uris= for uri: %s", uri)
client.start_playback(device_id=spotify_device_id, uris=[
uri], position_ms=position_ms)
elif uri.find("track") > 0:
_LOGGER.debug("Playing track using uris= for uri: %s", uri)
client.start_playback(device_id=spotify_device_id, uris=[
uri], position_ms=position_ms)
else:
if uri == "random":
_LOGGER.debug(
"Cool, you found the easter egg with playing a random" " playlist"
)
playlists = client.user_playlists("me", 50)
no_playlists = len(playlists["items"])
uri = playlists["items"][random.randint(
0, no_playlists - 1)]["uri"]
kwargs = {"device_id": spotify_device_id,
"context_uri": uri, "position_ms": position_ms}
if random_song:
if uri.find("album") > 0:
results = client.album_tracks(uri, market=country_code)
position = random.randint(0, int(results["total"]) - 1)
elif uri.find("playlist") > 0:
results = client.playlist_tracks(uri)
position = random.randint(0, int(results["total"]) - 1)
elif uri.find("collection") > 0:
results = client.current_user_saved_tracks()
position = random.randint(0, int(results["total"]) - 1)
_LOGGER.debug(
"Start playback at random position: %s", position)
if uri.find("artist") < 1:
kwargs["offset"] = {"position": position}
_LOGGER.debug(
(
'Playing context uri using context_uri for uri: "%s" '
"(random_song: %s)"
),
uri,
random_song,
)
client.start_playback(**kwargs)
def get_playlists(
self,
account: str,
playlist_type: str,
country_code: str,
locale: str,
limit: int,
) -> dict:
client = self.get_spotify_client(account)
resp = {}
if playlist_type == "discover-weekly":
playlist_type = "made-for-x"
if playlist_type == "user" or playlist_type == "default" or playlist_type == "":
resp = client.current_user_playlists(limit=limit)
elif playlist_type == "featured":
resp = client.featured_playlists(
locale=locale,
country=country_code,
timestamp=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
limit=limit,
offset=0,
)
resp = resp.get("playlists")
else:
resp = client._get(
"views/" + playlist_type,
content_limit=limit,
locale=locale,
platform="web",
types="album,playlist,artist,show,station",
limit=limit,
offset=0,
)
resp = resp.get("content")
return resp
@@ -0,0 +1,148 @@
"""
Controller to interface with Spotify.
"""
from __future__ import annotations
import hashlib
import json
import logging
import threading
import requests
import json
import hashlib
from .const import APP_SPOTIFY
from .error import LaunchError
import requests
from pychromecast.controllers import BaseController
from .const import APP_SPOTIFY
APP_NAMESPACE = "urn:x-cast:com.spotify.chromecast.secure.v1"
TYPE_GET_INFO = "getInfo"
TYPE_GET_INFO_RESPONSE = "getInfoResponse"
TYPE_ADD_USER = "addUser"
TYPE_ADD_USER_RESPONSE = "addUserResponse"
TYPE_ADD_USER_ERROR = "addUserError"
# pylint: disable=too-many-instance-attributes
class SpotifyController(BaseController):
"""Controller to interact with Spotify namespace."""
def __init__(self, castDevice, access_token=None, expires=None):
super(SpotifyController, self).__init__(APP_NAMESPACE, APP_SPOTIFY)
self.logger = logging.getLogger(__name__)
self.session_started = False
self.access_token = access_token
self.expires = expires
self.is_launched = False
self.device = None
self.credential_error = False
self.waiting = threading.Event()
self.castDevice = castDevice
def receive_message(self, _message, data: dict):
"""
Handle the auth flow and active player selection.
Called when a message is received.
"""
if data["type"] == TYPE_GET_INFO_RESPONSE:
self.device = self.getSpotifyDeviceID()
self.client = data["payload"]["clientID"]
headers = {
"authority": "spclient.wg.spotify.com",
"authorization": "Bearer {}".format(self.access_token),
"content-type": "text/plain;charset=UTF-8",
}
request_body = json.dumps(
{"clientId": self.client, "deviceId": self.device}
)
response = requests.post(
"https://spclient.wg.spotify.com/device-auth/v1/refresh",
headers=headers,
data=request_body,
)
json_resp = response.json()
self.send_message(
{
"type": TYPE_ADD_USER,
"payload": {
"blob": json_resp["accessToken"],
"tokenType": "accesstoken",
},
}
)
if data["type"] == TYPE_ADD_USER_RESPONSE:
self.is_launched = True
self.waiting.set()
if data["type"] == TYPE_ADD_USER_ERROR:
self.device = None
self.credential_error = True
self.waiting.set()
return True
def launch_app(self, timeout=10):
"""
Launch Spotify application.
Will raise a LaunchError exception if there is no response from the
Spotify app within timeout seconds.
"""
if self.access_token is None or self.expires is None:
raise ValueError("access_token and expires cannot be empty")
def callback(*_):
"""Callback function"""
self.send_message(
{
"type": TYPE_GET_INFO,
"payload": {
"remoteName": self.castDevice.cast_info.friendly_name,
"deviceID": self.getSpotifyDeviceID(),
"deviceAPI_isGroup": False,
},
}
)
self.device = None
self.credential_error = False
self.waiting.clear()
self.launch(callback_function=callback)
counter = 0
while counter < (timeout + 1):
if self.is_launched:
return
self.waiting.wait(1)
counter += 1
if not self.is_launched:
raise LaunchError(
"Timeout when waiting for status response from Spotify app"
)
# pylint: disable=too-many-locals
def quick_play(self, **kwargs):
"""
Launches the spotify controller and returns when it's ready.
To actually play media, another application using spotify
connect is required.
"""
self.access_token = kwargs["access_token"]
self.expires = kwargs["expires"]
self.launch_app(timeout=20)
def getSpotifyDeviceID(self) -> str:
"""
Retrieve the Spotify deviceID from provided chromecast info
"""
return hashlib.md5(self.castDevice.cast_info.friendly_name.encode()).hexdigest()