484 lines
17 KiB
Python
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
|