Initial Home Assistant commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user