Initial Home Assistant commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
class LaunchError(Exception):
|
||||
"""When an app fails to launch."""
|
||||
|
||||
class TokenError(Exception):
|
||||
pass
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user