diff --git a/README.md b/README.md index 93100a5..3fe03d8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Spotify Voice Assistant for Home Assistant +# Spotify Voice Assistant (SVA) for Home Assistant **Voice-controlled Spotify playback using natural language.** Just say "Play Coldplay on the kitchen speaker" and your music starts playing - no complex YAML patterns and no cookie authentication. @@ -18,7 +18,6 @@ Before using this integration, you need: - You want voice-controlled Spotify without running a full music server - You use conversation agents with function calling (Extended OpenAI Conversation, OpenAI, Gemini, etc.) - You want direct Spotify Connect integration with minimal dependencies -- You prefer simple, maintainable code (<120 lines) **Consider alternatives if:** - You need multi-provider music aggregation (Spotify + local files + streaming services) → Use Music Assistant @@ -26,32 +25,16 @@ Before using this integration, you need: - You use Home Assistant Assist voice pipeline → Music Assistant has [native voice support](https://github.com/music-assistant/voice-support) - You need advanced Spotify API features → Use SpotifyPlus -**Use both together:** Many users run Music Assistant for UI/management and this integration for voice control via custom conversation agents. +> **Important:** This integration provides the search functionality - your conversation agent's LLM model and hardware determine the actual voice command accuracy and response time. For local voice pipelines, qwen3:4b is a good starting point. See [Performance](#performance) for optimization guidance. -> **⚠️ Important:** This integration provides the search functionality - your conversation agent's LLM model and hardware determine the actual voice command accuracy and response time. For local voice pipelines, qwen3:4b is a pretty good starting point. See [Performance](#performance) for optimization guidance. +## Key Features -## The Problem - -Want to control Spotify with your voice in Home Assistant? You'll quickly hit these issues: - -- **Spotcast** requires cookie authentication (`sp_dc`, `sp_key`) which breaks frequently with "serverTime" errors -- **Music Assistant** is a full-featured music server - excellent for complete music management, but requires running a separate server and aggregates multiple providers -- **SpotifyPlus** is feature-rich but complex to set up for simple voice commands -- **Custom Sentences** require rigid YAML patterns - "Play {artist} on {speaker}" instead of natural language -- **Built-in Spotify integration** has no search service for voice assistants to use -- **Standard Spotify search returns wrong artists** - Personalized recommendations instead of exact matches (ask for Coldplay, get Taylor Swift) - -## The Solution - -A lightweight integration designed specifically for voice assistants with function calling: - -✅ **Direct Spotify Connect integration** - No music server required, uses Home Assistant's official Spotify integration -✅ **Natural language from day one** - "Play Coldplay" not "play artist equals Coldplay" -✅ **Zero additional authentication** - Reuses your existing Home Assistant Spotify OAuth -✅ **Exact match artist search** - Finds Coldplay when you ask for Coldplay, not recommendations -✅ **Works with any Spotify Connect device** - WiiM, Sonos, Google Cast, Echo, whatever you have -✅ **Under 120 lines of code** - Simple, maintainable, easy to understand -✅ **Voice assistant ready** - Complete conversation agent examples included +- **Natural language voice control** - "Play Coldplay" not "play artist equals Coldplay" +- **Zero additional authentication** - Reuses your existing Home Assistant Spotify OAuth +- **Exact match search** - Finds Coldplay when you ask for Coldplay, not recommendations +- **Works with any Spotify Connect device** - WiiM, Sonos, Google Cast, Echo, whatever you have +- **Lightweight and simple** - Direct Spotify Connect integration, no music server required +- **Complete examples included** - Function calling config for Extended OpenAI Conversation ## Comparison with Alternatives @@ -59,28 +42,29 @@ A lightweight integration designed specifically for voice assistants with functi |---------|------------------------|----------|-----------------|-------------| | **Primary Focus** | Voice search for conversation agents | Casting | Complete music server | Spotify API wrapper | | **Architecture** | Direct Spotify Connect | Spotify API | Music server (aggregates providers) | Spotify API | -| **Lines of Code** | <120 | 1000+ | 10,000+ | 5000+ | +| **Complexity** | Minimal | Moderate | High | High | | **Authentication** | Reuses HA OAuth | Cookies (breaks often) | Own server auth | Own OAuth flow | | **Setup Complexity** | 1 YAML line | Cookies + config | Full server install | Complex config | -| **Exact Artist Match** | ✅ Yes | ❌ No | ✅ Yes | ✅ Yes | -| **Voice Support** | ✅ Function calling | ⚠️ Manual scripts | ✅ HA Assist integration | ⚠️ Manual scripts | -| **Natural Language** | ✅ Via conversation agent | ❌ Rigid patterns | ✅ Via HA Assist | ❌ Manual calls | +| **Exact Artist Match** | Yes | No | Yes | Yes | +| **Voice Support** | Function calling | Manual scripts | HA Assist integration | Manual scripts | +| **Natural Language** | Via conversation agent | Rigid patterns | Via HA Assist | Manual calls | | **External Dependencies** | None | None | Separate server | None | | **Hardware Support** | Any Spotify Connect | Chromecast focused | Universal | Any Spotify Connect | ## Features -- ✅ **Voice-first design** - Built for natural language from day one -- ✅ **Hardware agnostic** - Works with any Spotify Connect device -- ✅ **Search by type** - Artists, albums, tracks, and playlists -- ✅ **Exact match preference** - Finds what you ask for across all content types, not recommendations -- ✅ **Smart playlist search** - Checks your personal playlists first, then falls back to public Spotify playlists -- ✅ **Complete examples** - Extended OpenAI Conversation config included -- ✅ **Playback control** - Pause, play, skip, volume, shuffle - all via voice -- ✅ **Artist radio mode** - Automatically shuffles when playing artists for dynamic playlists -- ✅ **Zero config authentication** - Leverages existing Spotify integration -- ✅ **Detailed logging** - Debug mode shows exactly what's happening -- ✅ **Service response support** - Returns data to automations/scripts +- **Voice-first design** - Built for natural language from day one +- **Hardware agnostic** - Works with any Spotify Connect device +- **Search by type** - Artists, albums, tracks, and playlists +- **Exact match preference** - Finds what you ask for across all content types, not recommendations +- **Smart playlist search** - Checks your personal playlists first, then falls back to public Spotify playlists +- **Complete examples** - Extended OpenAI Conversation config included +- **Playback control** - Pause, play, skip, volume, shuffle - all via voice +- **Play and queue modes** - Replace current playback or add to queue seamlessly +- **Artist radio mode** - Automatically shuffles when playing artists for dynamic playlists +- **Zero config authentication** - Leverages existing Spotify integration +- **Detailed logging** - Debug mode shows exactly what's happening +- **Service response support** - Returns data to automations/scripts ## Quick Start @@ -114,145 +98,25 @@ spotify_search: ### 3. Configure Extended OpenAI Conversation -Copy these functions to Extended OpenAI Conversation settings: +**Add Functions:** -
-Click to expand function configuration +Copy the function configuration from [`examples/extended_openai_functions.yaml`](examples/extended_openai_functions.yaml) to your Extended OpenAI Conversation settings. -```yaml -- spec: - name: search_spotify - description: Search Spotify for an artist, album, track, or playlist and return the Spotify URI. Use this before playing music to get the URI. - parameters: - type: object - properties: - query: - type: string - description: Artist, album, track, or playlist name to search for (e.g., "Coldplay", "Parachutes", "Yellow", "Today's Top Hits") - type: - type: string - enum: [artist, album, track, playlist] - description: Type of content to search for. Playlist searches check your personal playlists first, then fall back to public Spotify playlists. - required: - - query - - type - function: - type: script - sequence: - - service: spotify_search.search - response_variable: _function_result - data: - query: "{{ query }}" - type: "{{ type }}" +This includes: +- `search_spotify` - Search for music and get Spotify URIs +- `play_music` - Play music immediately (replaces current playback) +- `queue_music` - Add music to queue (doesn't interrupt playback) +- `control_playback` - Pause, play, skip, volume, shuffle controls -- spec: - name: play_music - description: Play music on a Spotify Connect device using a Spotify URI. You must first call search_spotify to get the URI. - parameters: - type: object - properties: - spotify_uri: - type: string - description: Spotify URI from search_spotify (e.g., "spotify:artist:4gzpq5DPGxSnKTe4SA8HAU") - media_player: - type: string - description: Entity ID of Spotify Connect media player (e.g., "media_player.kitchen_speaker") - required: - - spotify_uri - - media_player - function: - type: script - sequence: - - service: media_player.play_media - target: - entity_id: "{{ media_player }}" - data: - media_content_id: "{{ spotify_uri }}" - media_content_type: "music" +**Add System Prompt:** -- spec: - name: control_playback - description: Control music playback (pause, resume, stop, next track, previous track, set volume, shuffle) - parameters: - type: object - properties: - action: - type: string - enum: [pause, play, stop, next_track, previous_track, volume_set, shuffle_on, shuffle_off] - description: Playback control action - media_player: - type: string - description: Entity ID of media player - volume_level: - type: number - description: Volume level (0-100) for volume_set action only - required: - - action - - media_player - function: - type: script - sequence: - - if: "{{ action == 'pause' }}" - then: - - service: media_player.media_pause - target: - entity_id: "{{ media_player }}" - - if: "{{ action == 'play' }}" - then: - - service: media_player.media_play - target: - entity_id: "{{ media_player }}" - - if: "{{ action == 'stop' }}" - then: - - service: media_player.media_stop - target: - entity_id: "{{ media_player }}" - - if: "{{ action == 'next_track' }}" - then: - - service: media_player.media_next_track - target: - entity_id: "{{ media_player }}" - - if: "{{ action == 'previous_track' }}" - then: - - service: media_player.media_previous_track - target: - entity_id: "{{ media_player }}" - - if: "{{ action == 'volume_set' }}" - then: - - service: media_player.volume_set - target: - entity_id: "{{ media_player }}" - data: - volume_level: "{{ volume_level / 100 }}" - - if: "{{ action == 'shuffle_on' }}" - then: - - service: media_player.shuffle_set - target: - entity_id: "{{ media_player }}" - data: - shuffle: true - - if: "{{ action == 'shuffle_off' }}" - then: - - service: media_player.shuffle_set - target: - entity_id: "{{ media_player }}" - data: - shuffle: false -``` -
+Copy the music playback rules from [`examples/system_prompt.txt`](examples/system_prompt.txt) to your Extended OpenAI Conversation system prompt. -Add this to your Extended OpenAI Conversation system prompt: - -``` -Music Playback: -- When asked to play music, follow this two-step process: 1) Call search_spotify to get the Spotify URI, 2) Call play_music with the URI and media player entity -- Available media players: media_player.kitchen_speaker, media_player.gaming_room_speaker, media_player.office_speaker -- Default speaker: If no speaker is specified, use media_player.kitchen_speaker -- Parse commands like "Play {Artist/Album/Track/Playlist}" and determine the media type automatically (artist, album, track, playlist, or user_playlist) -- IMPORTANT: Use type="user_playlist" when user says "my playlist", "my [playlist name]", or refers to their personal saved playlists. Use type="playlist" for public Spotify playlists like "Today's Top Hits" -- IMPORTANT: When playing an artist, always enable shuffle after starting playback by calling control_playback with action: shuffle_on to create a dynamic playlist experience -- For playback control (pause, skip, volume, shuffle), use the control_playback function -``` +Key behaviors configured: +- Play vs Queue command detection +- Automatic query cleaning (strips "play", "queue", location words) +- Smart type detection (artist/album/track/playlist) +- No confirmation prompts for immediate playback **Customization tips:** - **Entity IDs are descriptive** (like `media_player.kitchen_speaker`): Just list the entity IDs as shown above @@ -268,8 +132,11 @@ Music Playback: - "Play Coldplay on the kitchen speaker" - "Play the album Parachutes" - "Play Yellow by Coldplay" +- "Play the song Dark Side of the Moon by Pink Floyd" - "Play Today's Top Hits" - "Play my workout playlist" +- "Queue Wish You Were Here" +- "Add the album Dark Side of the Moon to queue" - "Pause the music" - "Skip to the next track" - "Set volume to 50%" @@ -286,6 +153,8 @@ Music Playback: - "Put on some music from that British band with Chris Martin" - "Play my chill vibes playlist" - "Put on my running music" +- "Queue up some Pink Floyd for later" +- "Add Comfortably Numb to the queue" **Conversational Playback Control:** - "Make it louder" (instead of "Set volume to 70") @@ -298,105 +167,21 @@ Music Playback: ## Why Natural Language Matters -Traditional voice assistants require specific sentence patterns: -``` -Intent Pattern: "Play [artist] on [speaker]" -✅ Works: "Play Coldplay on kitchen speaker" -❌ Fails: "I want to listen to Coldplay" -❌ Fails: "Put on some Coldplay" -❌ Fails: "Start playing Coldplay in the kitchen" -``` - -This integration uses LLM-based function calling to understand conversational requests: -``` -LLM Understanding: Extracts intent from natural speech -✅ Works: "Play Coldplay on kitchen speaker" -✅ Works: "I want to listen to Coldplay" -✅ Works: "Put on some Coldplay" -✅ Works: "Start playing Coldplay in the kitchen" -✅ Works: "Play me that British band with Chris Martin" -``` - -The LLM understands **what you mean**, not just **what you say**. +Traditional voice assistants require exact patterns like "Play [artist] on [speaker]". LLM-based function calling understands conversational requests: "I want to listen to Coldplay", "Put on some Coldplay", or even "Play me that British band with Chris Martin" all work naturally. ## Compatible Speakers -This integration works with **any Spotify Connect-compatible device**: +Works with **any Spotify Connect-compatible device** that appears in Home Assistant as a `media_player` entity. -### Tested & Verified -- **WiiM Audio Pro** (and all WiiM speakers) -- **Sonos speakers** (all models with Spotify) -- **Google Nest/Home speakers** -- **Amazon Echo with Spotify** +**Requirements:** +- Spotify Premium account (required for Spotify Connect) +- Device appears in Home Assistant's Spotify integration +- Device supports Spotify Connect -### Should Work (Spotify Connect Compatible) -- **Smart Speakers:** - - Apple HomePod (with Spotify app) - - Bose Home Speaker series - - JBL Link series - - Harman Kardon Citation series - - Bang & Olufsen Beosound series +**Tested devices:** WiiM speakers, Sonos, Google Nest/Home, Amazon Echo -- **Streaming Devices:** - - Chromecast Audio - - Roku with Spotify channel - - Fire TV with Spotify - - Apple TV with Spotify app +**Should work:** Any smart speaker, AV receiver, streaming device, or computer with Spotify Connect support. This includes HomePod, Chromecast, Fire TV, Raspberry Pi with [Raspotify](https://github.com/dtcooper/raspotify), and more. -- **AV Receivers:** - - Denon HEOS-enabled receivers - - Yamaha MusicCast receivers - - Marantz NR/SR series - - Pioneer VSX series with Spotify - -- **DIY Solutions:** - - Raspberry Pi with [Raspotify](https://github.com/dtcooper/raspotify) - - Raspberry Pi with [Spotifyd](https://github.com/Spotifyd/spotifyd) - - Any Linux/Mac/Windows computer running Spotify - -- **Mobile Devices:** - - Phones/tablets running Spotify app (for testing) - -### Requirements -- **Spotify Premium account** (Spotify Connect requires Premium) -- **Device must show up in Home Assistant** as a `media_player` entity -- **Device must support Spotify Connect** (check manufacturer specs) - -**Note:** If your device runs Spotify and shows up in Home Assistant's integrations, it will work with this integration. - -## Works Alongside Music Assistant - -Music Assistant and this integration serve different purposes and work well together: - -### Music Assistant -- **Purpose:** Full-featured music server with rich UI, multi-room audio, queue management -- **Architecture:** Music server that aggregates multiple providers (Spotify, local files, streaming services) -- **Voice support:** [Built-in voice integration](https://github.com/music-assistant/voice-support) works with Home Assistant Assist (with or without LLM enhancement) -- **Best for:** Complete music management solution with visual control - -### This Integration (Spotify Voice Assistant) -- **Purpose:** Lightweight Spotify search service for conversation agents with function calling support -- **Architecture:** Direct Spotify Connect integration via Home Assistant's official Spotify integration (no music server required) -- **Works with:** Any conversation agent that supports function calling (Extended OpenAI Conversation, OpenAI Conversation, Google Generative AI Conversation, etc.) -- **Best for:** Adding Spotify voice control without running a full music server - -### Using Both Together - -Many users run both simultaneously: -- **Music Assistant:** Provides UI for manual control, queue management, and automations (skip voice setup) -- **This integration:** Handles voice control via custom conversation agents -- **They work together:** Music started via voice appears in Music Assistant UI and remains fully controllable there - -**Example workflow:** Say "Play Coldplay on office speaker" → music starts → open Music Assistant UI → see what's playing, adjust volume, skip tracks, manage queue. - -They don't directly integrate but work together because both control the same Spotify Connect devices independently. - -### Choose Your Setup - -- **Voice only:** Use this integration alone -- **Voice + rich UI:** Use both (Music Assistant without voice + this integration) -- **Home Assistant Assist voice:** Use Music Assistant's [built-in voice support](https://github.com/music-assistant/voice-support) -- **Custom conversation agent voice:** Use this integration ## How It Works @@ -453,25 +238,7 @@ The integration leverages Home Assistant's official Spotify integration: ### Performance Optimization -The integration includes smart caching for optimal performance: - -**Spotify Client Cache:** -- **First search:** ~1.6-5.6ms (finds and caches Spotify client) -- **Subsequent searches:** ~0.1ms (uses cached client) -- **Performance gain:** 15-50x faster on repeated searches - -**User Playlist Cache:** -- **First user_playlist search:** Fetches all user playlists from Spotify API -- **Subsequent user_playlist searches:** Uses cached playlist data -- **Performance gain:** Significantly faster for users with large playlist libraries - -**Cache Features:** -- Automatically validates cached client on every call -- Self-invalidates when Spotify integration is reloaded or removed -- Caches user playlists to avoid repeated API calls -- Manual cache clearing available via `spotify_search.clear_cache` service - -The cache is module-level and process-scoped, so it clears automatically on Home Assistant restart. +The integration uses smart caching to speed up repeated searches. The Spotify client and user playlists are cached automatically. Cache clears on Home Assistant restart or can be cleared manually via the `spotify_search.clear_cache` service. ## Advanced Usage @@ -551,20 +318,6 @@ automation: media_content_type: "music" ``` -### Prompt Optimization for Multi-part Queries - -For better accuracy when users provide both song/album AND artist names, enhance your Extended OpenAI Conversation system prompt to combine search terms: - -``` -Music Query Optimization: -- When user provides both song/album AND artist, combine them into the search query -- Example: "Play Yellow by Coldplay" → search_spotify(query="Yellow Coldplay", type="track") -- Example: "Play Parachutes album by Coldplay" → search_spotify(query="Parachutes Coldplay", type="album") -- The combined query improves search accuracy by providing more context to Spotify -``` - -This approach leverages Spotify's search algorithm to naturally weight both terms, improving accuracy when multiple artists have songs/albums with the same name. - ### Clear Cache Service If you experience issues or want to refresh cached data (e.g., after adding new playlists to your Spotify library), you can manually clear the cache: @@ -660,12 +413,12 @@ Typical "Play Coldplay" command: **1. Choose a faster LLM** - **Slow (2-5 min):** Large models on CPU (qwen3:8b, llama3:8b) -- **Medium (5-15 sec):** Small local models (qwen3:1.8b, phi3:mini) +- **Medium (5-15 sec):** Small local models (qwen3:4b, qwen3:1.8b) - **Fast (2-5 sec):** Cloud APIs (OpenAI GPT-4o-mini, Anthropic Claude Haiku) **2. Reduce exposed devices** -Each device in your conversation agent adds tokens to every LLM call, slowing processing: +Avoid exposing unnecessary devices to your voice assistant. Each device in your conversation agent adds tokens to every LLM call, slowing processing: - **38 devices:** ~1,600 tokens = slower responses - **25 devices:** ~1,000 tokens = 20-30% faster @@ -677,16 +430,7 @@ Each device in your conversation agent adds tokens to every LLM call, slowing pr **3. Enable GPU acceleration** If using Ollama locally, GPU acceleration provides 5-10x speedup over CPU. - -### Expected Performance - -| Configuration | Response Time | -|---------------|---------------| -| Cloud API (GPT-4o-mini) + 25 devices | 2-5 seconds ✅ | -| Local GPU (qwen3:8b) + 25 devices | 10-30 seconds | -| Local CPU (qwen3:8b) + 38 devices | 2-5 minutes ❌ | - -**Bottom line:** This integration adds <1 second overhead. LLM choice determines user experience. +If using Docker, remember, [GPU support isn't enabled by default](https://docs.docker.com/compose/how-tos/gpu-support/). ## Roadmap diff --git a/TEST_CASES.md b/TEST_CASES.md index 1cb4714..dfd18d4 100644 --- a/TEST_CASES.md +++ b/TEST_CASES.md @@ -46,9 +46,9 @@ Test suite for validating Spotify Voice Assistant behavior with natural language - **Result:** - **Pass/Fail:** -**Test 2.2: Album with artist (combined query)** +**Test 2.2: Album with artist** - **Command:** "Play Parachutes by Coldplay" -- **Expected:** Searches for album "Parachutes Coldplay", plays correct album +- **Expected:** Searches for album "Parachutes", plays correct album - **Result:** - **Pass/Fail:** @@ -80,9 +80,9 @@ Test suite for validating Spotify Voice Assistant behavior with natural language - **Result:** - **Pass/Fail:** -**Test 3.2: Track with artist (combined query)** +**Test 3.2: Track with artist** - **Command:** "Play Yellow by Coldplay" -- **Expected:** Searches for track "Yellow Coldplay", plays correct track +- **Expected:** Searches for track "Yellow", plays correct track - **Result:** - **Pass/Fail:** @@ -116,13 +116,13 @@ Test suite for validating Spotify Voice Assistant behavior with natural language **Test 4.2: Common track name with artist** - **Command:** "Play Hurt by Nine Inch Nails" -- **Expected:** Searches for track "Hurt Nine Inch Nails", returns correct version +- **Expected:** Searches for track "Hurt", LLM context helps return correct version - **Result:** - **Pass/Fail:** **Test 4.3: Common track name with different artist** - **Command:** "Play Hurt by Johnny Cash" -- **Expected:** Searches for track "Hurt Johnny Cash", returns Cash cover version +- **Expected:** Searches for track "Hurt", LLM context helps return Cash cover version - **Result:** - **Pass/Fail:** @@ -202,7 +202,41 @@ Test suite for validating Spotify Voice Assistant behavior with natural language --- -### 7. Playback Control +### 7. Queue Management + +**Test 7.1: Queue track** +- **Command:** "Queue Wish You Were Here" +- **Expected:** Searches for track "Wish You Were Here", adds to queue without interrupting current playback +- **Result:** +- **Pass/Fail:** + +**Test 7.2: Add to queue with artist** +- **Command:** "Add Yellow by Coldplay to the queue" +- **Expected:** Searches for track "Yellow", adds to queue +- **Result:** +- **Pass/Fail:** + +**Test 7.3: Queue album** +- **Command:** "Add the album Dark Side of the Moon to queue" +- **Expected:** Searches for album "Dark Side of the Moon", adds to queue +- **Result:** +- **Pass/Fail:** + +**Test 7.4: Queue with speaker specified** +- **Command:** "Queue some Pink Floyd on the kitchen speaker" +- **Expected:** Searches for artist "Pink Floyd", adds to queue on kitchen speaker +- **Result:** +- **Pass/Fail:** + +**Test 7.5: Natural language queue** +- **Command:** "Queue up some Pink Floyd for later" +- **Expected:** Searches for artist "Pink Floyd", adds to queue +- **Result:** +- **Pass/Fail:** + +--- + +### 8. Playback Control **Test 7.1: Pause** - **Command:** "Pause the music" @@ -240,17 +274,35 @@ Test suite for validating Spotify Voice Assistant behavior with natural language - **Result:** - **Pass/Fail:** -**Test 7.7: Stop** +**Test 8.7: Stop** - **Command:** "Stop the music" - **Expected:** Stops playback - **Result:** - **Pass/Fail:** +**Test 8.8: Shuffle on** +- **Command:** "Shuffle on" +- **Expected:** Enables shuffle mode +- **Result:** +- **Pass/Fail:** + +**Test 8.9: Shuffle off** +- **Command:** "Turn shuffle off" +- **Expected:** Disables shuffle mode +- **Result:** +- **Pass/Fail:** + +**Test 8.10: Natural language shuffle** +- **Command:** "Shuffle this" +- **Expected:** Enables shuffle mode +- **Result:** +- **Pass/Fail:** + --- -### 8. Multi-Speaker Scenarios +### 9. Multi-Speaker Scenarios -**Test 8.1: Different speaker each time** +**Test 9.1: Different speaker each time** - **Commands:** - "Play Coldplay on the kitchen speaker" - "Play Radiohead on the gaming room speaker" @@ -259,7 +311,7 @@ Test suite for validating Spotify Voice Assistant behavior with natural language - **Result:** - **Pass/Fail:** -**Test 8.2: No speaker specified (uses default)** +**Test 9.2: No speaker specified (uses default)** - **Command:** "Play Coldplay" - **Expected:** Plays on default speaker from system prompt - **Result:** @@ -267,21 +319,21 @@ Test suite for validating Spotify Voice Assistant behavior with natural language --- -### 9. Error Handling +### 10. Error Handling -**Test 9.1: Non-existent artist** +**Test 10.1: Non-existent artist** - **Command:** "Play XYZ123NotARealBand" - **Expected:** Returns no results or best guess, handles gracefully - **Result:** - **Pass/Fail:** -**Test 9.2: Typo in artist name** +**Test 10.2: Typo in artist name** - **Command:** "Play Cold Play" (with space) - **Expected:** Spotify's fuzzy search finds "Coldplay" - **Result:** - **Pass/Fail:** -**Test 9.3: Invalid speaker** +**Test 10.3: Invalid speaker** - **Command:** "Play Coldplay on the bedroom speaker" (if bedroom speaker doesn't exist) - **Expected:** Error or fallback behavior - **Result:** @@ -289,27 +341,27 @@ Test suite for validating Spotify Voice Assistant behavior with natural language --- -### 10. Natural Language Variations +### 11. Natural Language Variations -**Test 10.1: Informal request** +**Test 11.1: Informal request** - **Command:** "Put on some Coldplay" - **Expected:** Searches for artist "Coldplay", plays on default speaker - **Result:** - **Pass/Fail:** -**Test 10.2: Question format** +**Test 11.2: Question format** - **Command:** "Can you play Coldplay?" - **Expected:** Searches for artist "Coldplay", plays on default speaker - **Result:** - **Pass/Fail:** -**Test 10.3: Context-heavy request** +**Test 11.3: Context-heavy request** - **Command:** "I want to listen to Coldplay right now" - **Expected:** Searches for artist "Coldplay", plays on default speaker - **Result:** - **Pass/Fail:** -**Test 10.4: Mood-based request** +**Test 11.4: Mood-based request** - **Command:** "Play something chill from Coldplay" - **Expected:** Searches for artist "Coldplay", plays on default speaker - **Result:** @@ -319,7 +371,7 @@ Test suite for validating Spotify Voice Assistant behavior with natural language ## Results Summary -**Total Tests:** 40 +**Total Tests:** 53 **Passed:** _____ **Failed:** _____ **Pass Rate:** _____% diff --git a/TODO.md b/TODO.md index 5658103..b7f99bf 100644 --- a/TODO.md +++ b/TODO.md @@ -1,76 +1,88 @@ # TODO -Development roadmap and enhancement proposals for Spotify Voice Assistant. +Development roadmap and future enhancements for Spotify Voice Assistant. -## Future Enhancements +**Current Version:** v1.0.0 -### 1. Artist Filter Parameter -Add optional artist filter parameter to improve multi-part query accuracy. +## Completed in v1.0.0 -**Current approach (v1.0.0):** -- Combine artist and song into query string: "Yellow Coldplay" -- Relies on Spotify's search ranking algorithm -- Works well via LLM prompt enhancement - -**Proposed enhancement:** -- Add `artist` parameter to `search_spotify` service -- Apply artist filtering to search results -- Example: `search_spotify(query="Yellow", type="track", artist="Coldplay")` - -**Benefits:** -- More precise matching when multiple artists have songs with same name (e.g., "Hurt" by Nine Inch Nails vs Johnny Cash) -- Explicit artist filtering vs relying on search ranking -- Better handling of edge cases - -**Implementation notes:** -- Update `services.yaml` to add `artist` parameter (optional) -- Update `search_spotify` function in `__init__.py` to apply artist filtering -- Update function definition in Extended OpenAI Conversation -- Consider backward compatibility - make parameter optional -- Update documentation and examples - -**Trade-offs:** -- Requires more specific parameter extraction from LLM -- May reduce flexibility of natural language queries -- Current combined query approach already works well for most cases +- Exact match search for all content types (artists, albums, tracks, playlists) +- Smart playlist search (user playlists first, then public) +- Queue functionality (play vs queue modes) +- Shuffle controls +- Performance caching (Spotify client + user playlists) +- Clear cache service +- Comprehensive test cases --- -### 2. ~~Extend Exact Name Matching to Albums and Tracks~~ ✅ COMPLETED (v1.0.0) -~~Apply the same "exact match" logic currently used for artists to albums and tracks.~~ +## Future Enhancements -**Status:** Implemented in v1.0.0 -- All search types (artist, album, track, playlist) now use exact name matching -- Searches use limit=10 and check for exact matches before falling back to first result -- Consistent behavior across all content types -- Logs indicate match type (exact/first/partial) for debugging +### 1. Podcast Support -**Also implemented:** -- Playlist exact matching with same logic -- User playlist search with exact and partial matching -- Caching for user playlists to improve performance +Add support for searching and playing podcasts and podcast episodes. + +**Current limitation:** +- Integration only supports music content (artists, albums, tracks, playlists) + +**Proposed:** +- Add `type="podcast"` for searching podcasts (shows) +- Add `type="episode"` for searching specific podcast episodes +- Support "Play the latest episode of [podcast name]" +- Support "Play [episode name] from [podcast name]" + +**Implementation:** +- Use Spotify API search types: `show` and `episode` +- Consider caching user's saved/followed podcasts +- Handle episode-specific logic (latest vs specific episode) +- Update Extended OpenAI function definitions +- Update system prompts with podcast examples +- Add podcast test cases to TEST_CASES.md + +**API endpoints:** +- `client.search(query, ["show"])` for podcast search +- `client.search(query, ["episode"])` for episode search +- `client.get_show_episodes(show_id)` for getting episodes + +--- + +### 2. Saved Content Quick Access + +Extend saved/favorite content search beyond playlists. + +**Currently supported:** +- User playlists via `type="playlist"` (searches user first, then public) + +**Proposed additions:** +- `type="saved_tracks"` for liked songs +- `type="saved_albums"` for saved albums +- `type="followed_artists"` for followed artists + +**Benefits:** +- "Play from my liked songs" +- "Play from my saved albums" +- Faster access to frequently played content + +**Implementation:** +- Use Spotify API: `get_saved_tracks()`, `get_saved_albums()`, `get_followed_artists()` +- Handle pagination for large libraries +- Apply similar caching strategy as user playlists +- Add search/filter capabilities within saved content --- ### 3. Configuration Options -Allow users to configure integration behavior via Home Assistant UI or YAML. -**Proposed configurable options:** +Allow users to configure integration behavior via YAML or UI. + +**Proposed options:** - Number of results to check for exact match (currently hardcoded to 10) +- Cache duration/invalidation behavior +- Default search type when ambiguous +- Logging verbosity level - Whether to require exact match or allow fuzzy matching -- Default search type (artist/album/track) when not specified -- Cache duration/behavior -- Logging verbosity -**Current behavior:** -- All behavior is hardcoded in the integration - -**Benefits:** -- Users can tune behavior for their specific needs without modifying code -- Easier troubleshooting with configurable logging -- Flexibility for different use cases - -**Implementation notes:** +**Implementation:** - Add config flow for UI-based configuration - Support YAML configuration in `configuration.yaml` - Ensure sensible defaults @@ -78,86 +90,104 @@ Allow users to configure integration behavior via Home Assistant UI or YAML. --- -### 4. Integration with HA Assist -Make the integration work with Home Assistant's built-in voice pipeline (Assist) without requiring Extended OpenAI Conversation. +### 4. Artist Filter Parameter -**Current behavior:** -- Requires Extended OpenAI Conversation (or similar) to provide function calling interface +Add optional artist parameter for more precise filtering. -**Proposed enhancement:** -- Register as a native Home Assistant intent/sentence pattern -- Allow Assist to use it directly via standard voice pipeline +**Current approach:** +- LLM strips artist from query per system prompt +- Relies on Spotify's search ranking + +**Proposed:** +- Add optional `artist` parameter to `search_spotify` service +- Example: `search_spotify(query="Yellow", type="track", artist="Coldplay")` **Benefits:** -- Users who want to use HA's default voice pipeline (Whisper → built-in LLM → actions) could use this integration -- Lower barrier to entry for non-technical users +- More precise matching for common track names (e.g., "Hurt" by NIN vs Johnny Cash) +- Explicit filtering vs relying on search ranking **Trade-offs:** -- Would likely still require intent patterns like "Play [artist] on [device]" +- Requires LLM to extract both query and artist separately +- May reduce natural language flexibility +- Current approach works well for most cases + +**Implementation:** +- Update `services.yaml` to add optional `artist` parameter +- Apply artist filtering in `__init__.py` +- Update Extended OpenAI function definition +- Maintain backward compatibility + +--- + +### 5. HA Assist Integration + +Make integration work with Home Assistant's built-in voice pipeline without requiring Extended OpenAI Conversation. + +**Current limitation:** +- Requires Extended OpenAI Conversation (or similar) for function calling + +**Proposed:** +- Register as native Home Assistant intent/sentence pattern +- Work directly with Assist voice pipeline + +**Trade-offs:** +- Would likely require rigid intent patterns ("Play [artist] on [device]") - Less flexible than LLM-based approach - May lose natural language advantage +- Music Assistant already provides this via their voice support -**Implementation notes:** -- Register intent handlers via Home Assistant's conversation integration -- Define sentence patterns for common use cases -- Consider maintaining both approaches (function calling + intent patterns) +**Consider:** +- Whether this adds value given Music Assistant's HA Assist integration +- Could maintain both approaches (function calling + intent patterns) --- -### 5. Saved/Favorite Content Quick Access -Add ability to search/play from user's Spotify saved content (liked songs, saved albums, followed artists). +### 6. Multi-room/Group Playback -**Status:** Partially implemented in v1.0.0 -- ✅ User playlists: `type="user_playlist"` searches only saved playlists -- ✅ Caching implemented for user playlist data -- ⏳ Future: Saved tracks, albums, and followed artists - -**Implemented in v1.0.0:** -- Voice commands like "Play my workout playlist" now work -- Searches only user's saved playlists (not all of Spotify) -- Exact and partial matching for user playlists -- Performance-optimized with caching - -**Still to implement:** -- `type="saved_tracks"` for liked songs -- `type="saved_albums"` for saved albums -- `type="followed_artists"` for followed artists - -**Implementation notes:** -- Use Spotify API endpoints: `get_saved_tracks()`, `get_saved_albums()` -- Handle pagination for large libraries -- Apply similar caching strategy as user playlists - ---- - -### 6. Podcast Support -Add support for searching and playing podcasts and podcast episodes. +Add support for playing on speaker groups or multiple devices simultaneously. **Current behavior:** -- Integration only supports music content (artists, albums, tracks, playlists) -- Podcasts are not searchable or playable +- Plays on single device specified in command -**Proposed enhancement:** -- Add `type="podcast"` for searching podcasts (shows) -- Add `type="episode"` for searching specific podcast episodes -- Support playing latest episode: "Play the latest episode of [podcast name]" -- Support playing specific episodes: "Play [episode name] from [podcast name]" +**Proposed:** +- Support speaker groups defined in Home Assistant +- "Play Coldplay on all speakers" +- "Play Coldplay in the downstairs" -**Benefits:** -- Complete Spotify content coverage -- Voice control for podcast listening -- Consistent experience across all Spotify content types +**Implementation:** +- Detect speaker groups from media_player entities +- Update LLM prompt to understand group concepts +- Test with various Spotify Connect group configurations -**Implementation notes:** -- Use Spotify API search types: `show` and `episode` -- Consider caching user's saved/followed podcasts -- Handle episode-specific logic (latest vs specific episode) -- Update Extended OpenAI function definitions -- Update system prompts with podcast examples -- Add podcast test cases +--- -**API endpoints needed:** -- `client.search(query, ["show"])` for podcast search -- `client.search(query, ["episode"])` for episode search -- `client.get_show_episodes(show_id)` for getting episodes -- Potentially: saved/followed shows endpoint +### 7. Playback Context Awareness + +Add awareness of what's currently playing for smarter commands. + +**Examples:** +- "Play more like this" - queue similar artists/tracks +- "Who is this?" - return current track/artist info +- "Add this to my workout playlist" - save current track + +**Implementation:** +- Query current playback state from Spotify integration +- Add new service calls for context-aware operations +- Update LLM functions with current playback info + +--- + +## Non-Goals + +Things we explicitly won't implement: + +1. **Advanced Spotify API features** - Use SpotifyPlus for this +2. **Multi-provider music aggregation** - Use Music Assistant for this +3. **Rich UI/queue management** - Use Music Assistant for this +4. **Cookie-based authentication** - Sticking with OAuth via HA's Spotify integration + +--- + +## Contributing + +Have ideas for enhancements? Open an issue or submit a PR on GitHub. diff --git a/custom_components/spotify_search/__init__.py b/custom_components/spotify_search/__init__.py index 3495706..aaac1e8 100644 --- a/custom_components/spotify_search/__init__.py +++ b/custom_components/spotify_search/__init__.py @@ -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 diff --git a/custom_components/spotify_search/services.yaml b/custom_components/spotify_search/services.yaml index afce546..554c9e9 100644 --- a/custom_components/spotify_search/services.yaml +++ b/custom_components/spotify_search/services.yaml @@ -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. \ No newline at end of file diff --git a/examples/configuration.yaml b/examples/configuration.yaml index 1464903..70652bf 100644 --- a/examples/configuration.yaml +++ b/examples/configuration.yaml @@ -4,7 +4,7 @@ # Spotify Voice Assistant Integration spotify_search: -# Optional: Enable debug logging +# Optional: Enable debug logging as needed logger: default: info logs: diff --git a/examples/extended_openai_functions.yaml b/examples/extended_openai_functions.yaml index 3c904a0..93c4c68 100644 --- a/examples/extended_openai_functions.yaml +++ b/examples/extended_openai_functions.yaml @@ -1,5 +1,44 @@ # Extended OpenAI Conversation Function Configuration -# Add these to your Extended OpenAI Conversation integration settings +# Add these to your existing Extended OpenAI Conversation integration function configuration. + +# NOTE: Ensure that the native function 'execute_service' is defined in your Home Assistant instance. +# If so, skip this one and just grab the other functions below to add. +- spec: + name: execute_services + description: Use this function to execute service of devices in Home Assistant. + parameters: + type: object + properties: + list: + type: array + items: + type: object + properties: + domain: + type: string + description: The domain of the service + service: + type: string + description: The service to be called + service_data: + type: object + description: The service data object to indicate what to control. + properties: + entity_id: + type: string + description: The entity_id retrieved from available devices. + required: + - entity_id + required: + - domain + - service + - service_data + function: + type: native + name: execute_service + + +# Spotify Music Control Functions - spec: name: search_spotify @@ -28,7 +67,7 @@ - spec: name: play_music - description: Play music on a Spotify Connect device using a Spotify URI. You must first call search_spotify to get the URI. + description: Play music immediately on a Spotify Connect device using a Spotify URI, replacing current playback. You must first call search_spotify to get the URI. Use this when user says "play". parameters: type: object properties: @@ -50,6 +89,33 @@ data: media_content_id: "{{ spotify_uri }}" media_content_type: "music" + enqueue: replace + +- spec: + name: queue_music + description: Add music to the queue on a Spotify Connect device using a Spotify URI, without interrupting current playback. You must first call search_spotify to get the URI. Use this when user says "queue" or "add to queue". + parameters: + type: object + properties: + spotify_uri: + type: string + description: Spotify URI from search_spotify (e.g., "spotify:artist:4gzpq5DPGxSnKTe4SA8HAU") + media_player: + type: string + description: Entity ID of Spotify Connect media player (e.g., "media_player.kitchen_speaker") + required: + - spotify_uri + - media_player + function: + type: script + sequence: + - service: media_player.play_media + target: + entity_id: "{{ media_player }}" + data: + media_content_id: "{{ spotify_uri }}" + media_content_type: "music" + enqueue: add - spec: name: control_playback diff --git a/examples/system_prompt.txt b/examples/system_prompt.txt index f41e107..98bbf01 100644 --- a/examples/system_prompt.txt +++ b/examples/system_prompt.txt @@ -1,8 +1,35 @@ -Music Playback: -- When asked to play music, follow this two-step process: 1) Call search_spotify to get the Spotify URI, 2) Call play_music with the URI and media player entity -- Available media players: media_player.kitchen_speaker, media_player.living_room_speaker (customize with your actual entity IDs) -- Parse commands like "Play {Artist/Album/Track/Playlist}" and determine the media type automatically (artist, album, track, or playlist) -- For playlists, use type="playlist" - the integration automatically checks personal playlists first, then falls back to public Spotify playlists -- IMPORTANT: When playing an artist, always enable shuffle after starting playback by calling control_playback with action: shuffle_on to create a dynamic playlist experience -- For playback control (pause, skip, volume, shuffle), use the control_playback function -- Always use natural language interpretation - users will say things like "play Coldplay" or "play my workout playlist" not "search for artist Coldplay" +Add these to your existing conversation agent prompts + +--- + +Music Playback Rules: +1. Play vs Queue Commands: + a. "Play [music]" → ALWAYS search_spotify + play_music (replaces current playback immediately, NEVER ask for confirmation) + b. "Queue [music]", "Add [music] to queue", or "Queue up [music]" → ALWAYS search_spotify + queue_music (adds to queue, NEVER ask for confirmation) +2. When processing music requests, you must perform TWO steps: + a. Call search_spotify to find the URI + b. Call play_music (for "play") OR queue_music (for "queue/add/queue up") + IMPORTANT: + - When user says "play", execute immediately without asking, even if music is already playing + - When user says "queue" or "add to queue", execute immediately without asking + - Both commands should NEVER prompt for confirmation +3. Search Query Cleaning: + - Strip command words like "play", "queue", "listen to", "songs by", "album", "track" + - Strip location info like "on the kitchen speaker", "in the bathroom" + - Examples: + * "Play songs by Taylor Swift on the kitchen speaker" → query="Taylor Swift", type="track", use play_music + * "Queue Dark Side of the Moon in the kitchen" → query="Dark Side of the Moon", type="album", use queue_music + * "Play Yellow by Coldplay" → query="Yellow", type="track", use play_music + * "Add Wish You Were Here to queue in the bathroom" → query="Wish You Were Here", type="track", use queue_music +4. Type Detection: + - Artist only: "Play Coldplay" → type="artist" + - Explicit "song"/"track": "Play the song Yellow" → type="track" + - Explicit "album": "Play the album Parachutes" → type="album" + - "[content] by [artist]": Default to type="album" (integration will auto-fallback to track if no album match) + - Playlist or genre: "Play my workout playlist" or "Play jazz" → type="playlist" +5. Device Selection: + - If user specifies a room, infer entity ID (e.g., "kitchen" → media_player.kitchen_speaker) + - If no room specified, use media_player.gaming_room_speaker as default +6. Special Rules: + - When playing an artist, always enable shuffle after playback starts + - Playlists are automatically searched in user's library first, then public Spotify playlists