diff --git a/README.md b/README.md index 46b304b..93100a5 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,9 @@ A lightweight integration designed specifically for voice assistants with functi - ✅ **Voice-first design** - Built for natural language from day one - ✅ **Hardware agnostic** - Works with any Spotify Connect device -- ✅ **Search by type** - Artists, albums, tracks, playlists, and your personal playlists +- ✅ **Search by type** - Artists, albums, tracks, and playlists - ✅ **Exact match preference** - Finds what you ask for across all content types, not recommendations -- ✅ **Personal playlist access** - Search within your saved Spotify playlists with "play my workout playlist" +- ✅ **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 @@ -131,8 +131,8 @@ Copy these functions to Extended OpenAI Conversation settings: 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, user_playlist] - description: Type of content to search for. Use 'user_playlist' when user says "my playlist" or refers to their personal playlists. + 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 @@ -428,18 +428,20 @@ This integration uses smart matching across all content types to avoid Spotify's **Why this matters:** Standard Spotify search prioritizes personalized recommendations. If you search "Coldplay," you might get Taylor Swift if you listen to her frequently. Our exact matching ensures you get Coldplay when you ask for Coldplay. -### User Playlist Search +### Smart Playlist Search -When you say "my playlist" or "my [playlist name]", the integration searches only within your saved Spotify playlists: +Playlist searches automatically check your personal playlists first, then fall back to public Spotify playlists: -1. Retrieves your personal Spotify library playlists -2. First looks for exact name match (case-insensitive) -3. Falls back to partial match if no exact match found -4. Returns helpful error if no matching playlist exists +1. Cleans the query (removes "playlist" and "playlists" from search terms) +2. Searches your saved Spotify playlists for exact match +3. If no exact match, searches your playlists for partial match +4. If still not found, searches public Spotify playlists +5. Returns exact match from public results if found, otherwise first result **Examples:** -- "Play my workout playlist" → Searches only your saved playlists -- "Play Today's Top Hits" → Searches all public Spotify playlists +- "Play my workout playlist" → Checks your saved playlists first +- "Play vibes" → Finds your "Vibes" playlist if you have one, otherwise searches public playlists +- "Play Today's Top Hits" → Checks your playlists first, then finds public playlist ### No Additional Authentication diff --git a/TEST_CASES.md b/TEST_CASES.md index 3580b1f..1cb4714 100644 --- a/TEST_CASES.md +++ b/TEST_CASES.md @@ -164,41 +164,39 @@ Test suite for validating Spotify Voice Assistant behavior with natural language ### 6. Playlist Queries -**Test 6.1: Public playlist - exact match** -- **Command:** "Play Today's Top Hits" -- **Expected:** Searches public playlists, returns exact match for "Today's Top Hits" -- **Result:** -- **Pass/Fail:** - -**Test 6.2: Public playlist - common name** -- **Command:** "Play Chill Vibes" -- **Expected:** Searches public playlists with limit=10, prefers exact "Chill Vibes" match over "Chill Vibes Mix" -- **Result:** -- **Pass/Fail:** - -**Test 6.3: User playlist - exact match** +**Test 6.1: User playlist - exact match** - **Command:** "Play my workout playlist" -- **Expected:** Searches only user's saved playlists, finds exact match for "workout" +- **Expected:** Removes "playlist" from query, searches user's playlists for exact match "workout" - **Result:** - **Pass/Fail:** -**Test 6.4: User playlist - partial match** +**Test 6.2: User playlist - partial match** - **Command:** "Play my running music" -- **Expected:** If no exact "running music" match, finds partial match like "Running Music 2024" +- **Expected:** Checks user playlists first, finds partial match like "Running Music 2024" - **Result:** - **Pass/Fail:** -**Test 6.5: User playlist - not found** -- **Command:** "Play my xyz123 playlist" -- **Expected:** Returns error "No playlist matching 'xyz123' found in your library" +**Test 6.3: User playlist - simple name** +- **Command:** "Play vibes" +- **Expected:** Checks user playlists first for "Vibes", falls back to public playlists if not found - **Result:** - **Pass/Fail:** -**Test 6.6: Playlist vs user playlist distinction** -- **Command 1:** "Play RapCaviar" -- **Expected 1:** Uses type=playlist, searches all public playlists -- **Command 2:** "Play my RapCaviar" -- **Expected 2:** Uses type=user_playlist, searches only user's saved playlists (assuming user has saved RapCaviar) +**Test 6.4: Public playlist fallback - exact match** +- **Command:** "Play Today's Top Hits" +- **Expected:** Removes "playlist" if present, checks user playlists first, then finds in public playlists with exact match +- **Result:** +- **Pass/Fail:** + +**Test 6.5: Public playlist fallback - common name** +- **Command:** "Play Chill Vibes" +- **Expected:** Checks user playlists first, then searches public playlists with exact match preference +- **Result:** +- **Pass/Fail:** + +**Test 6.6: Playlist not found anywhere** +- **Command:** "Play xyz123 playlist" +- **Expected:** Searches user playlists, then public playlists, returns error if not found - **Result:** - **Pass/Fail:** diff --git a/TODO.md b/TODO.md index c780097..5658103 100644 --- a/TODO.md +++ b/TODO.md @@ -127,3 +127,37 @@ Add ability to search/play from user's Spotify saved content (liked songs, saved - 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. + +**Current behavior:** +- Integration only supports music content (artists, albums, tracks, playlists) +- Podcasts are not searchable or playable + +**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]" + +**Benefits:** +- Complete Spotify content coverage +- Voice control for podcast listening +- Consistent experience across all Spotify content types + +**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 diff --git a/custom_components/spotify_search/__init__.py b/custom_components/spotify_search/__init__.py index f766586..3495706 100644 --- a/custom_components/spotify_search/__init__.py +++ b/custom_components/spotify_search/__init__.py @@ -7,7 +7,7 @@ from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) DOMAIN = "spotify_search" -VALID_SEARCH_TYPES = {"artist", "album", "track", "playlist", "user_playlist"} +VALID_SEARCH_TYPES = {"artist", "album", "track", "playlist"} # Cache Spotify client to avoid repeated lookups _spotify_cache = { @@ -142,17 +142,68 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.warning("No artists found for query: %s", query) return {"error": f"No artist found for: {query}"} elif search_type == "playlist": - # For playlists, search with higher limit and check for exact matches - _LOGGER.debug("Searching for playlist: %s", query) - results = await client.search(query, ["playlist"], limit=10) + # For playlists, search user's playlists first, then fall back to Spotify + _LOGGER.info("Searching for playlist: %s", query) + + # 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 + 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) + 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"} + + # 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"} + + _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) + + # Step 2: Fall back to Spotify public playlist search + _LOGGER.info("Searching Spotify public playlists for: %s", query_cleaned) + 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 - query_lower = query.lower() for playlist in items_list: - # Defensive attribute check - if hasattr(playlist, "name") and playlist.name.lower() == query_lower: + if hasattr(playlist, "name") and playlist.name.lower() == query_cleaned: exact_match = playlist break @@ -167,69 +218,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: uri = selected_playlist.uri name = selected_playlist.name match_type = "exact match" if exact_match else "first result" - _LOGGER.info("Found Spotify playlist: %s (%s) - %s", name, uri, match_type) + _LOGGER.info("Found Spotify public playlist: %s (%s) - %s", name, uri, match_type) return {"uri": uri, "name": name, "type": "playlist"} else: - _LOGGER.warning("No playlists found for query: %s", query) + _LOGGER.warning("No playlists found for query: %s", query_cleaned) return {"error": f"No playlist found for: {query}"} - elif search_type == "user_playlist": - # Search within user's saved playlists - _LOGGER.debug("Searching user's playlists for: %s", query) - try: - # Get user's playlists (with caching) - if _spotify_cache["user_playlists"] is None: - _LOGGER.debug("Cache miss, fetching user playlists") - user_playlists = await client.get_playlists_for_current_user() - _spotify_cache["user_playlists"] = user_playlists - else: - _LOGGER.debug("Using cached user playlists") - user_playlists = _spotify_cache["user_playlists"] - - if not user_playlists or not hasattr(user_playlists, "items"): - _LOGGER.warning("No user playlists found or invalid response") - return {"error": "Could not retrieve user playlists"} - - items_list = user_playlists.items - if not items_list or len(items_list) == 0: - _LOGGER.warning("User has no saved playlists") - return {"error": "No saved playlists found"} - - # Search for exact match in user's playlists - exact_match = None - query_lower = query.lower() - for playlist in items_list: - if hasattr(playlist, "name") and playlist.name.lower() == query_lower: - exact_match = playlist - break - - # If no exact match, search for partial match - partial_match = None - if not exact_match: - for playlist in items_list: - if hasattr(playlist, "name") and query_lower in playlist.name.lower(): - partial_match = playlist - break - - selected_playlist = exact_match or partial_match - - if not selected_playlist: - _LOGGER.warning("No matching playlist found in user's library for: %s", query) - return {"error": f"No playlist matching '{query}' found in your library"} - - # Defensive attribute checks - if not hasattr(selected_playlist, "uri") or not hasattr(selected_playlist, "name"): - _LOGGER.error("User playlist result missing required attributes") - return {"error": "Invalid playlist data"} - - uri = selected_playlist.uri - name = selected_playlist.name - match_type = "exact match" if exact_match else "partial match" - _LOGGER.info("Found user playlist: %s (%s) - %s", name, uri, match_type) - return {"uri": uri, "name": name, "type": "playlist"} - - except Exception as err: - _LOGGER.error("Error retrieving user playlists: %s", err) - return {"error": "Failed to retrieve user playlists"} else: # For albums, tracks - search with higher limit and check for exact matches _LOGGER.debug("Searching for %s: %s", search_type, query) diff --git a/custom_components/spotify_search/services.yaml b/custom_components/spotify_search/services.yaml index 3aeeb54..afce546 100644 --- a/custom_components/spotify_search/services.yaml +++ b/custom_components/spotify_search/services.yaml @@ -11,7 +11,7 @@ search: text: type: name: Type - description: Type of content to search for. All search types use exact name matching when possible. Use 'user_playlist' to search only within your saved playlists. + 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. required: false default: "artist" example: "artist" @@ -22,7 +22,6 @@ search: - "album" - "track" - "playlist" - - "user_playlist" clear_cache: name: Clear Cache diff --git a/examples/extended_openai_functions.yaml b/examples/extended_openai_functions.yaml index 8ffc1b3..3c904a0 100644 --- a/examples/extended_openai_functions.yaml +++ b/examples/extended_openai_functions.yaml @@ -12,8 +12,8 @@ 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, user_playlist] - description: Type of content to search for. Use 'user_playlist' when user says "my playlist" or refers to their personal playlists. + 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 diff --git a/examples/system_prompt.txt b/examples/system_prompt.txt index 2cdd7dc..f41e107 100644 --- a/examples/system_prompt.txt +++ b/examples/system_prompt.txt @@ -1,8 +1,8 @@ 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, 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" +- 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"