Update documentation for v1.0.0 release

Major documentation updates:
- Streamline README by removing redundant sections
- Link to example files instead of duplicating configuration
- Fix typos and update model recommendations
- Add GPU acceleration documentation link
- Remove outdated "Prompt Optimization" section

Test suite improvements:
- Add comprehensive queue management tests (5 new tests)
- Add shuffle control tests (3 new tests)
- Fix combined query references to match current system prompt
- Update total test count to 53
- Renumber all test sections for consistency
- TODO refresh
This commit is contained in:
Chad Auld
2025-11-20 18:14:06 -07:00
parent db0303d8a9
commit 4d477fa218
8 changed files with 506 additions and 589 deletions
+108 -122
View File
@@ -1,7 +1,7 @@
"""Spotify Search Integration for Home Assistant."""
import logging
import re
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -17,26 +17,47 @@ _spotify_cache = {
}
def clean_query(query: str, search_type: str) -> str:
"""Remove common command words to improve search accuracy."""
query = query.lower().strip()
# Remove "play" from the start of any query
if query.startswith("play "):
query = query[5:]
# Remove type-specific filler words
if search_type == "artist":
# Remove "artist" prefix if LLM included it
query = query.replace("artist ", "").replace(
"group ", "").replace("band ", "")
elif search_type == "album":
# Remove "album" prefix
query = query.replace("album ", "")
elif search_type == "track":
# Remove "song" or "track" prefix
query = query.replace("song ", "").replace("track ", "")
return " ".join(query.split()).strip()
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Spotify Search component."""
async def get_spotify_client():
"""Get Spotify client with caching and validation."""
# Validate cache if it exists
if _spotify_cache["client"] is not None:
# Fast check: Does entity still exist?
if _spotify_cache["entity_id"] in hass.states.async_entity_ids():
_LOGGER.debug("Using cached Spotify client")
return _spotify_cache["client"]
else:
_LOGGER.info("Cached Spotify entity no longer exists, invalidating cache")
_LOGGER.info(
"Cached Spotify entity no longer exists, invalidating cache")
_spotify_cache["client"] = None
_spotify_cache["entity_id"] = None
_spotify_cache["user_playlists"] = None
# Cache miss or invalidated - do full lookup
_LOGGER.debug("Cache miss, performing Spotify entity lookup")
# Try to find the Spotify media player entity
spotify_entity_id = None
for state in hass.states.async_all("media_player"):
if "spotify" in state.entity_id.lower():
@@ -47,15 +68,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_LOGGER.error("No Spotify media player entity found")
raise LookupError("Spotify not configured")
_LOGGER.debug("Found Spotify entity: %s", spotify_entity_id)
# Get the Spotify integration's data through entity platform
entity_component = hass.data.get("entity_components", {}).get("media_player")
entity_component = hass.data.get(
"entity_components", {}).get("media_player")
if not entity_component:
_LOGGER.error("Media player component not found")
raise LookupError("Media player component not available")
# Find the Spotify entity object
spotify_entity = None
for entity in entity_component.entities:
if entity.entity_id == spotify_entity_id:
@@ -63,24 +80,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
break
if not spotify_entity:
_LOGGER.error("Spotify entity %s not found in entities", spotify_entity_id)
raise LookupError("Spotify entity not available")
# Access the Spotify client from the coordinator
if not hasattr(spotify_entity, "coordinator"):
_LOGGER.error("Spotify entity does not have a coordinator")
raise AttributeError("Spotify coordinator not available")
coordinator = spotify_entity.coordinator
if not hasattr(coordinator, "client"):
_LOGGER.error("Spotify coordinator does not have a client attribute")
raise AttributeError("Spotify client not available")
client = coordinator.client
_LOGGER.debug("Spotify client type: %s", type(client).__name__)
# Cache for future calls
_spotify_cache["client"] = client
_spotify_cache["entity_id"] = spotify_entity_id
_LOGGER.info("Cached Spotify client for entity: %s", spotify_entity_id)
@@ -89,18 +98,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def search_spotify(call: ServiceCall):
"""Search Spotify and return the first result's URI."""
query = call.data.get("query")
raw_query = call.data.get("query")
search_type = call.data.get("type", "artist")
if not query:
if not raw_query:
_LOGGER.error("No query provided to spotify_search")
return {"error": "No query provided"}
# Validate search type
if search_type not in VALID_SEARCH_TYPES:
_LOGGER.error("Invalid search type: %s", search_type)
return {"error": f"Invalid type. Must be one of: {', '.join(VALID_SEARCH_TYPES)}"}
# --- UPDATE: Clean the query before sending to Spotify ---
query = clean_query(raw_query, search_type)
_LOGGER.info("Searching Spotify (%s) for cleaned query: '%s' (raw: '%s')",
search_type, query, raw_query)
try:
client = await get_spotify_client()
except (LookupError, AttributeError) as err:
@@ -108,156 +120,131 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return {"error": str(err)}
try:
# Search Spotify using the integration's client (async method)
# SpotifyClient.search signature: search(query: str, types: list[SearchType], *, limit: int = 48)
if search_type == "artist":
# For artists, search using the artist type directly to get better matches
_LOGGER.debug("Searching for artist: %s", query)
results = await client.search(query, ["artist"], limit=10)
items_list = results.artists
if items_list and len(items_list) > 0:
# Check if any artist name matches exactly (case-insensitive)
exact_match = None
query_lower = query.lower()
for artist in items_list:
# Defensive attribute check
if hasattr(artist, "name") and artist.name.lower() == query_lower:
exact_match = artist
break
# Use exact match if found, otherwise use first result
selected_artist = exact_match if exact_match else items_list[0]
# Defensive attribute checks
if not hasattr(selected_artist, "uri") or not hasattr(selected_artist, "name"):
_LOGGER.error("Artist result missing required attributes")
if not hasattr(selected_artist, "uri"):
return {"error": "Invalid artist data from Spotify"}
uri = selected_artist.uri
name = selected_artist.name
match_type = "exact match" if exact_match else "first result"
_LOGGER.info("Found Spotify artist: %s (%s) - %s", name, uri, match_type)
return {"uri": uri, "name": name, "type": "artist"}
result = {"uri": uri, "name": name, "type": "artist"}
_LOGGER.info("✅ SEARCH RESULT (%s): %s",
match_type, result)
return result
else:
_LOGGER.warning("No artists found for query: %s", query)
return {"error": f"No artist found for: {query}"}
error_result = {"error": f"No artist found for: {query}"}
_LOGGER.error("❌ SEARCH ERROR: %s", error_result)
return error_result
elif search_type == "playlist":
# For playlists, search user's playlists first, then fall back to Spotify
_LOGGER.info("Searching for playlist: %s", query)
# Cleaning is already handled by clean_query logic above,
# but we keep the specific 'playlist' word removal for safety
query_cleaned = query.lower().replace(
"playlist", "").replace("playlists", "").strip()
# Clean query: remove "playlist" and "playlists" from search term
query_cleaned = query.lower()
for word in ["playlist", "playlists"]:
query_cleaned = query_cleaned.replace(word, "")
query_cleaned = " ".join(query_cleaned.split()).strip() # Remove extra spaces
_LOGGER.info("Cleaned query: '%s' (original: '%s')", query_cleaned, query)
# Step 1: Search user's personal playlists first
# 1. Search User Library
try:
# Get user's playlists (with caching)
if _spotify_cache["user_playlists"] is None:
_LOGGER.info("Cache miss, fetching user playlists from Spotify API")
user_playlists_response = await client.get_playlists_for_current_user()
if user_playlists_response and hasattr(user_playlists_response, "items"):
items_list = user_playlists_response.items
_spotify_cache["user_playlists"] = items_list
_LOGGER.info("User playlists fetched and cached: %d playlists", len(items_list) if items_list else 0)
_spotify_cache["user_playlists"] = user_playlists_response.items
else:
_LOGGER.warning("Could not fetch user playlists")
_spotify_cache["user_playlists"] = []
else:
_LOGGER.info("Using cached user playlists")
user_playlists = _spotify_cache["user_playlists"]
if user_playlists and len(user_playlists) > 0:
playlist_names = [p.name if hasattr(p, "name") else "NO_NAME" for p in user_playlists]
_LOGGER.info("User's playlist names: %s", playlist_names)
# Search for exact match
for playlist in user_playlists:
if hasattr(playlist, "name") and playlist.name.lower() == query_cleaned:
uri = playlist.uri if hasattr(playlist, "uri") else None
name = playlist.name
if uri:
_LOGGER.info("Found in user playlists (exact match): %s (%s)", name, uri)
return {"uri": uri, "name": name, "type": "playlist"}
# Exact match in library
for playlist in user_playlists:
if hasattr(playlist, "name") and playlist.name.lower() == query_cleaned:
result = {"uri": playlist.uri,
"name": playlist.name, "type": "playlist"}
_LOGGER.info(
"✅ SEARCH RESULT (user library - exact match): %s", result)
return result
# Search for partial match
for playlist in user_playlists:
if hasattr(playlist, "name") and query_cleaned in playlist.name.lower():
uri = playlist.uri if hasattr(playlist, "uri") else None
name = playlist.name
if uri:
_LOGGER.info("Found in user playlists (partial match): %s (%s)", name, uri)
return {"uri": uri, "name": name, "type": "playlist"}
# Partial match in library
for playlist in user_playlists:
if hasattr(playlist, "name") and query_cleaned in playlist.name.lower():
result = {"uri": playlist.uri,
"name": playlist.name, "type": "playlist"}
_LOGGER.info(
"✅ SEARCH RESULT (user library - partial match): %s", result)
return result
_LOGGER.info("Playlist not found in user's library, searching Spotify public playlists")
except Exception as err:
_LOGGER.warning("Error searching user playlists: %s, falling back to Spotify search", err)
_LOGGER.warning("Error searching user playlists: %s", err)
# Step 2: Fall back to Spotify public playlist search
_LOGGER.info("Searching Spotify public playlists for: %s", query_cleaned)
# 2. Fallback to Public Search
results = await client.search(query_cleaned, ["playlist"], limit=10)
items_list = results.playlists
if items_list and len(items_list) > 0:
# Check if any playlist name matches exactly (case-insensitive)
exact_match = None
for playlist in items_list:
if hasattr(playlist, "name") and playlist.name.lower() == query_cleaned:
exact_match = playlist
break
# Use exact match if found, otherwise use first result
selected_playlist = exact_match if exact_match else items_list[0]
# Defensive attribute checks
if not hasattr(selected_playlist, "uri") or not hasattr(selected_playlist, "name"):
_LOGGER.error("Playlist result missing required attributes")
return {"error": "Invalid playlist data from Spotify"}
uri = selected_playlist.uri
name = selected_playlist.name
match_type = "exact match" if exact_match else "first result"
_LOGGER.info("Found Spotify public playlist: %s (%s) - %s", name, uri, match_type)
return {"uri": uri, "name": name, "type": "playlist"}
selected_playlist = items_list[0]
result = {"uri": selected_playlist.uri,
"name": selected_playlist.name, "type": "playlist"}
_LOGGER.info(
"✅ SEARCH RESULT (public playlist - first result): %s", result)
return result
else:
_LOGGER.warning("No playlists found for query: %s", query_cleaned)
return {"error": f"No playlist found for: {query}"}
error_result = {"error": f"No playlist found for: {query}"}
_LOGGER.error("❌ SEARCH ERROR: %s", error_result)
return error_result
else:
# For albums, tracks - search with higher limit and check for exact matches
_LOGGER.debug("Searching for %s: %s", search_type, query)
# Handle Album and Track
results = await client.search(query, [search_type], limit=10)
items_list = getattr(results, f"{search_type}s", None)
if items_list and len(items_list) > 0:
# Check if any item name matches exactly (case-insensitive)
exact_match = None
query_lower = query.lower()
for item in items_list:
# Defensive attribute check
if hasattr(item, "name") and item.name.lower() == query_lower:
exact_match = item
break
# Use exact match if found, otherwise use first result
# Fallback logic for Albums
if not exact_match and search_type == "album" and len(query.split()) >= 2:
_LOGGER.info(
"No exact album match for '%s', trying track search", query)
try:
track_results = await client.search(query, ["track"], limit=10)
track_items = getattr(
track_results, "tracks", None)
if track_items and len(track_items) > 0:
first_track = track_items[0]
result = {"uri": first_track.uri,
"name": first_track.name, "type": "track"}
_LOGGER.info(
"✅ SEARCH RESULT (album→track fallback): %s", result)
return result
except Exception:
pass
selected_item = exact_match if exact_match else items_list[0]
# Defensive attribute checks
if not hasattr(selected_item, "uri") or not hasattr(selected_item, "name"):
_LOGGER.error("%s result missing required attributes", search_type)
return {"error": f"Invalid {search_type} data from Spotify"}
uri = selected_item.uri
name = selected_item.name
match_type = "exact match" if exact_match else "first result"
_LOGGER.info("Found Spotify %s: %s (%s) - %s", search_type, name, uri, match_type)
return {"uri": uri, "name": name, "type": search_type}
result = {"uri": selected_item.uri,
"name": selected_item.name, "type": search_type}
_LOGGER.info("✅ SEARCH RESULT (%s - %s): %s",
search_type, match_type, result)
return result
else:
_LOGGER.warning("No results found for query: %s", query)
return {"error": f"No {search_type} found for: {query}"}
error_result = {
"error": f"No {search_type} found for: {query}"}
_LOGGER.error("❌ SEARCH ERROR: %s", error_result)
return error_result
except AttributeError as err:
_LOGGER.error("Spotify API returned unexpected data structure: %s", err)
return {"error": "Unexpected response from Spotify"}
except Exception as err:
_LOGGER.exception("Unexpected error searching Spotify")
return {"error": "Search failed"}
@@ -265,7 +252,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def clear_cache(call: ServiceCall):
"""Clear Spotify client and user playlists cache."""
if _spotify_cache["client"] is not None or _spotify_cache["user_playlists"] is not None:
_LOGGER.info("Manually clearing Spotify cache")
_spotify_cache["client"] = None
_spotify_cache["entity_id"] = None
_spotify_cache["user_playlists"] = None
+16 -4
View File
@@ -1,17 +1,25 @@
search:
name: Search Spotify
description: Search Spotify for artists, albums, tracks, or playlists and return the Spotify URI. Uses exact match when available. Leverages your existing Home Assistant Spotify integration - no additional authentication needed.
description: >
Search Spotify for artists, albums, tracks, or playlists and return the Spotify URI.
IMPORTANT: This service only finds the music URI; it does not play it.
Uses exact match when available. Leverages your existing Home Assistant Spotify integration.
fields:
query:
name: Query
description: Search query (artist, album, track, or playlist name)
description: >
The specific name to search for (e.g., "Coldplay", "Dark Side of the Moon").
Provide ONLY the name. Do not include command words like "play", "listen to", or "on the speaker".
required: true
example: "Coldplay"
selector:
text:
type:
name: Type
description: Type of content to search for. All search types use exact name matching when possible. Playlist searches check your personal playlists first, then fall back to public Spotify playlists.
description: >
Type of content to search for.
Use 'playlist' if the user asks for a Playlist OR a Genre/Mood (e.g., 'Rock', 'Jazz').
Playlist searches check your personal playlists first, then fall back to public Spotify playlists.
required: false
default: "artist"
example: "artist"
@@ -25,4 +33,8 @@ search:
clear_cache:
name: Clear Cache
description: Clear the cached Spotify client and user playlist data. The integration caches for performance (15-50x faster searches). Use this service after adding/removing playlists in Spotify or if you experience issues after removing or re-adding the Spotify integration. The cache automatically invalidates when the Spotify integration is reloaded.
description: >
Clear the cached Spotify client and user playlist data.
The integration caches for performance (15-50x faster searches).
Use this service after adding/removing playlists in Spotify or if you experience issues after removing or re-adding the Spotify integration.
The cache automatically invalidates when the Spotify integration is reloaded.