Files
HomeAssistance/custom_components/spotcast/spotcast_controller.py
T
2026-06-05 22:34:31 -04:00

484 lines
17 KiB
Python

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