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//"') 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::]" ) 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