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

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