Files
spotify-voice-assistant/CACHING_IMPLEMENTATION_PLAN.md
T
Chad Auld bd221b80e8 Initial release v1.0.0
Voice-controlled Spotify playback integration for Home Assistant

Features:
- Natural language music control via Extended OpenAI Conversation
- Exact match artist search (finds Coldplay when you ask for Coldplay)
- Support for artist, album, track, and playlist searches
- Smart caching for 15-50x performance improvement
- Works with any Spotify Connect device
- Zero additional authentication (reuses HA Spotify OAuth)
- Enterprise-grade code quality with defensive programming

Technical improvements:
- Client caching with automatic validation
- Input validation and error handling
- Defensive attribute checks
- Lazy logging for performance
- Clear error messages for users
2025-11-09 19:45:14 -07:00

11 KiB

Spotify Client Caching Implementation Plan

Problem Statement

Currently, the integration searches for the Spotify entity on every service call:

  • Iterates through all media player entities (O(n) operation)
  • Looks up entity object from component
  • Accesses coordinator and client
  • Repeats this 100% of the time, even though the result rarely changes

Solution: Intelligent Caching

Cache the Spotify client reference after first successful lookup, with smart invalidation.

Implementation Design

1. Cache Structure

# Module-level cache (survives across service calls)
_spotify_cache = {
    "client": None,           # SpotifyClient instance
    "entity_id": None,        # Entity ID for monitoring
    "last_validated": None,   # Timestamp of last validation
}

2. Cache Lifecycle

First Call:
  → Cache Miss
  → Lookup entity (slow)
  → Store client + entity_id
  → Return client

Subsequent Calls:
  → Cache Hit
  → Validate entity still exists (fast)
  → Return cached client

Entity Removed:
  → Validation fails
  → Clear cache
  → Re-lookup on next call

3. Validation Strategy

Fast validation - Check if entity still exists:

# Very fast - just checks if entity_id exists in state registry
if spotify_entity_id not in hass.states.async_entity_ids():
    # Entity was removed, invalidate cache
    _spotify_cache.clear()

When to validate:

  • Option A: Every call (minimal overhead, ~0.1ms)
  • Option B: Every N seconds (e.g., 60s)
  • Recommended: Option A - Simple and fast enough

4. Cache Invalidation Triggers

Automatic:

  1. Entity no longer exists (validation check)
  2. Integration reload/restart (cache is module-level, cleared on reload)

Manual (Future):

  1. Service call: spotify_search.clear_cache
  2. HA restart (automatic)
  3. Spotify integration reload (automatic via entity disappearance)

Implementation Code

"""Spotify Search Integration for Home Assistant."""
import logging
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.typing import ConfigType

_LOGGER = logging.getLogger(__name__)
DOMAIN = "spotify_search"

# Cache Spotify client to avoid repeated lookups
_spotify_cache = {
    "client": None,
    "entity_id": None,
}


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")
                _spotify_cache.clear()

        # Cache miss or invalidated - do full lookup
        _LOGGER.debug("Cache miss, performing Spotify entity lookup")

        # Find Spotify media player entity
        spotify_entity_id = None
        for state in hass.states.async_all("media_player"):
            if "spotify" in state.entity_id.lower():
                spotify_entity_id = state.entity_id
                break

        if not spotify_entity_id:
            raise LookupError("No Spotify media player entity found")

        # Get entity component
        entity_component = hass.data.get("entity_components", {}).get("media_player")
        if not entity_component:
            raise LookupError("Media player component not available")

        # Find Spotify entity object
        spotify_entity = None
        for entity in entity_component.entities:
            if entity.entity_id == spotify_entity_id:
                spotify_entity = entity
                break

        if not spotify_entity:
            raise LookupError(f"Spotify entity {spotify_entity_id} not found")

        # Get client from coordinator
        if not hasattr(spotify_entity, "coordinator"):
            raise AttributeError("Spotify entity missing coordinator")

        coordinator = spotify_entity.coordinator
        if not hasattr(coordinator, "client"):
            raise AttributeError("Coordinator missing client")

        client = coordinator.client

        # 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)

        return client

    async def search_spotify(call: ServiceCall):
        """Search Spotify and return the first result's URI."""
        query = call.data.get("query")
        search_type = call.data.get("type", "artist")

        if not query:
            _LOGGER.error("No query provided")
            return {"error": "No query provided"}

        try:
            client = await get_spotify_client()
        except (LookupError, AttributeError) as err:
            _LOGGER.error("Failed to get Spotify client: %s", err)
            return {"error": str(err)}

        # ... rest of search logic unchanged ...

Version 2: Time-Based (Alternative)

Add timestamp-based validation to reduce checks:

import time

_spotify_cache = {
    "client": None,
    "entity_id": None,
    "validated_at": 0,
}

VALIDATION_INTERVAL = 60  # Seconds between validations

async def get_spotify_client():
    """Get Spotify client with time-based cache validation."""
    now = time.time()

    if _spotify_cache["client"] is not None:
        # Only validate every N seconds
        if now - _spotify_cache["validated_at"] < VALIDATION_INTERVAL:
            return _spotify_cache["client"]

        # Time to validate
        if _spotify_cache["entity_id"] in hass.states.async_entity_ids():
            _spotify_cache["validated_at"] = now
            return _spotify_cache["client"]
        else:
            # Entity gone, clear cache
            _spotify_cache.clear()

    # ... lookup logic ...
    _spotify_cache["validated_at"] = now

Cache Clearing Service (Optional)

Add manual cache clearing for troubleshooting:

async def clear_cache(call: ServiceCall):
    """Clear Spotify client cache."""
    if _spotify_cache["client"] is not None:
        _LOGGER.info("Manually clearing Spotify client cache")
        _spotify_cache.clear()
        return {"success": True, "message": "Cache cleared"}
    else:
        return {"success": False, "message": "Cache was already empty"}

# Register both services
hass.services.async_register(DOMAIN, "search", search_spotify, supports_response="only")
hass.services.async_register(DOMAIN, "clear_cache", clear_cache, supports_response="only")

Add to services.yaml:

clear_cache:
  name: Clear Cache
  description: Clear the cached Spotify client. Use this if you're experiencing issues after removing/re-adding the Spotify integration.

Performance Impact

Before Caching

Each service call:
  - Iterate all media_player entities: ~1-5ms (depends on count)
  - Lookup entity object: ~0.5ms
  - Access coordinator: ~0.1ms
  Total: ~1.6-5.6ms per call

After Caching (V1 - Simple)

First call:
  - Same as before: ~1.6-5.6ms

Subsequent calls:
  - Check entity exists: ~0.1ms
  - Return cached client: ~0.01ms
  Total: ~0.11ms per call

Speedup: 15-50x faster

After Caching (V2 - Time-Based)

First call: ~1.6-5.6ms
Calls within 60s: ~0.01ms (no validation)
Validation call: ~0.11ms

Average: ~0.01-0.02ms per call
Speedup: 80-280x faster

Recommendation

For v1.1.0: Version 1 (Simple Validation)

Pros:

  • Simple implementation
  • Always validates (safer)
  • Validation is fast enough (~0.1ms)
  • No time tracking needed
  • Easy to understand

Cons:

  • Validates on every call (tiny overhead)

For v1.2.0+: Version 2 (Time-Based) - If needed

Only if profiling shows validation is a bottleneck (unlikely).

Testing Plan

Unit Tests (Future)

async def test_cache_hit():
    """Test that second call uses cache."""
    # First call
    client1 = await get_spotify_client()

    # Second call should use cache
    client2 = await get_spotify_client()

    assert client1 is client2  # Same object reference

async def test_cache_invalidation():
    """Test cache clears when entity removed."""
    # Get client (cached)
    client1 = await get_spotify_client()

    # Remove Spotify entity
    hass.states.async_remove("media_player.spotify_chad")

    # Next call should re-lookup and fail
    with pytest.raises(LookupError):
        await get_spotify_client()

Manual Testing

  1. Call service twice, check logs for "Using cached Spotify client"
  2. Remove Spotify integration, verify cache invalidates
  3. Re-add Spotify integration, verify it re-caches
  4. Restart HA, verify cache clears (new process)

Migration Path

v1.0.0 → v1.1.0

  • Add caching (no breaking changes)
  • Add cache clearing service (optional)
  • Update CHANGELOG

Code Changes Required

  • Modify async_setup() to add get_spotify_client() helper
  • Replace entity lookup in search_spotify() with await get_spotify_client()
  • Add module-level _spotify_cache dict
  • Optional: Add clear_cache service

Lines Changed: ~30 lines

  • Remove: ~15 lines (inline lookup)
  • Add: ~30 lines (cached lookup function)
  • Modify: ~5 lines (call new function)

Net: ~20 line increase for 15-50x performance improvement

Security Considerations

Is caching safe?

  • ✅ Client object doesn't contain credentials (OAuth tokens are in HA core)
  • ✅ Module-level cache is process-scoped (isolated per HA instance)
  • ✅ Cache invalidates when entity removed
  • ✅ No user data in cache
  • ✅ No cross-user data leakage (single-user system)

Risks:

  • ⚠️ If Spotify integration is removed and re-added quickly, cache might use old client
  • Mitigation: Validation check catches this (entity_id won't match)

Rollout Strategy

  1. v1.1.0-beta: Release with caching, ask users to test
  2. Monitor for issues (GitHub issues)
  3. If stable after 2 weeks → v1.1.0 stable
  4. Add to CHANGELOG with performance notes

Documentation Updates

README.md

## Performance

The integration caches the Spotify client reference after first use for optimal performance:
- First search: ~5ms
- Subsequent searches: ~0.1ms (50x faster)

The cache automatically invalidates if the Spotify integration is removed or reloaded.

DEVELOPMENT.md

## Caching

The integration caches the Spotify client to avoid repeated entity lookups.

To manually clear the cache:
```yaml
service: spotify_search.clear_cache

This is rarely needed but useful for troubleshooting.


## Summary

**Recommended Implementation: Version 1 (Simple Validation)**
- Cache client after first lookup
- Validate entity exists on every call (~0.1ms overhead)
- Automatic invalidation when entity removed
- Optional manual cache clearing service
- 15-50x performance improvement
- Simple, safe, effective

**Estimated effort:** 1-2 hours development + testing
**Risk level:** Low
**Performance gain:** High