530 lines
15 KiB
Python
530 lines
15 KiB
Python
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
|