diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index ebc7de940..e28acd41b 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -500,6 +500,15 @@ def _print_setup_summary(config: dict, hermes_home): if get_env_value("HASS_TOKEN"): tool_status.append(("Smart Home (Home Assistant)", True, None)) + # Spotify (OAuth via hermes auth spotify — check auth.json, not env vars) + try: + from hermes_cli.auth import get_provider_auth_state + _spotify_state = get_provider_auth_state("spotify") or {} + if _spotify_state.get("access_token") or _spotify_state.get("refresh_token"): + tool_status.append(("Spotify (PKCE OAuth)", True, None)) + except Exception: + pass + # Skills Hub if get_env_value("GITHUB_TOKEN"): tool_status.append(("Skills Hub (GitHub)", True, None)) diff --git a/skills/media/spotify/SKILL.md b/skills/media/spotify/SKILL.md new file mode 100644 index 000000000..612eec16f --- /dev/null +++ b/skills/media/spotify/SKILL.md @@ -0,0 +1,134 @@ +--- +name: spotify +description: Control Spotify — play music, search the catalog, manage playlists and library, inspect devices and playback state. Loads when the user asks to play/pause/queue music, search tracks/albums/artists, manage playlists, or check what's playing. Assumes the Hermes Spotify toolset is enabled and `hermes auth spotify` has been run. +version: 1.0.0 +author: Hermes Agent +license: MIT +prerequisites: + tools: [spotify_playback, spotify_devices, spotify_queue, spotify_search, spotify_playlists, spotify_albums, spotify_library] +metadata: + hermes: + tags: [spotify, music, playback, playlists, media] + related_skills: [gif-search] +--- + +# Spotify + +Control the user's Spotify account via the Hermes Spotify toolset (7 tools). Setup guide: https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify + +## When to use this skill + +The user says something like "play X", "pause", "skip", "queue up X", "what's playing", "search for X", "add to my X playlist", "make a playlist", "save this to my library", etc. + +## The 7 tools + +- `spotify_playback` — play, pause, next, previous, seek, set_repeat, set_shuffle, set_volume, get_state, get_currently_playing, recently_played +- `spotify_devices` — list, transfer +- `spotify_queue` — get, add +- `spotify_search` — search the catalog +- `spotify_playlists` — list, get, create, add_items, remove_items, update_details +- `spotify_albums` — get, tracks +- `spotify_library` — list/save/remove with `kind: "tracks"|"albums"` + +Playback-mutating actions require Spotify Premium; search/library/playlist ops work on Free. + +## Canonical patterns (minimize tool calls) + +### "Play " +One search, then play by URI. Do NOT loop through search results describing them unless the user asked for options. + +``` +spotify_search({"query": "miles davis kind of blue", "types": ["album"], "limit": 1}) +→ got album URI spotify:album:1weenld61qoidwYuZ1GESA +spotify_playback({"action": "play", "context_uri": "spotify:album:1weenld61qoidwYuZ1GESA"}) +``` + +For "play some " (no specific song), prefer `types: ["artist"]` and play the artist context URI — Spotify handles smart shuffle. If the user says "the song" or "that track", search `types: ["track"]` and pass `uris: [track_uri]` to play. + +### "What's playing?" / "What am I listening to?" +Single call — don't chain get_state after get_currently_playing. + +``` +spotify_playback({"action": "get_currently_playing"}) +``` + +If it returns 204/empty (`is_playing: false`), tell the user nothing is playing. Don't retry. + +### "Pause" / "Skip" / "Volume 50" +Direct action, no preflight inspection needed. + +``` +spotify_playback({"action": "pause"}) +spotify_playback({"action": "next"}) +spotify_playback({"action": "set_volume", "volume_percent": 50}) +``` + +### "Add to my playlist" +1. `spotify_playlists list` to find the playlist ID by name +2. Get the track URI (from currently playing, or search) +3. `spotify_playlists add_items` with the playlist_id and URIs + +``` +spotify_playlists({"action": "list"}) +→ found "Late Night Jazz" = 37i9dQZF1DX4wta20PHgwo +spotify_playback({"action": "get_currently_playing"}) +→ current track uri = spotify:track:0DiWol3AO6WpXZgp0goxAV +spotify_playlists({"action": "add_items", + "playlist_id": "37i9dQZF1DX4wta20PHgwo", + "uris": ["spotify:track:0DiWol3AO6WpXZgp0goxAV"]}) +``` + +### "Create a playlist called X and add the last 3 songs I played" +``` +spotify_playback({"action": "recently_played", "limit": 3}) +spotify_playlists({"action": "create", "name": "Focus 2026"}) +→ got playlist_id back in response +spotify_playlists({"action": "add_items", "playlist_id": , "uris": [<3 uris>]}) +``` + +### "Save / unsave / is this saved?" +Use `spotify_library` with the right `kind`. + +``` +spotify_library({"kind": "tracks", "action": "save", "uris": ["spotify:track:..."]}) +spotify_library({"kind": "albums", "action": "list", "limit": 50}) +``` + +### "Transfer playback to my " +``` +spotify_devices({"action": "list"}) +→ pick the device_id by matching name/type +spotify_devices({"action": "transfer", "device_id": "", "play": true}) +``` + +## Critical failure modes + +**`403 Forbidden — No active device found`** on any playback action means Spotify isn't running anywhere. Tell the user: "Open Spotify on your phone/desktop/web player first, start any track for a second, then retry." Don't retry the tool call blindly — it will fail the same way. You can call `spotify_devices list` to confirm; an empty list means no active device. + +**`403 Forbidden — Premium required`** means the user is on Free and tried to mutate playback. Don't retry; tell them this action needs Premium. Reads still work (search, playlists, library, get_state). + +**`204 No Content` on `get_currently_playing`** is NOT an error — it means nothing is playing. The tool returns `is_playing: false`. Just report that to the user. + +**`429 Too Many Requests`** = rate limit. Wait and retry once. If it keeps happening, you're looping — stop. + +**`401 Unauthorized` after a retry** — refresh token revoked. Tell the user to run `hermes auth spotify` again. + +## URI and ID formats + +Spotify uses three interchangeable ID formats. The tools accept all three and normalize: + +- URI: `spotify:track:0DiWol3AO6WpXZgp0goxAV` (preferred) +- URL: `https://open.spotify.com/track/0DiWol3AO6WpXZgp0goxAV` +- Bare ID: `0DiWol3AO6WpXZgp0goxAV` + +When in doubt, use full URIs. Search results return URIs in the `uri` field — pass those directly. + +Entity types: `track`, `album`, `artist`, `playlist`, `show`, `episode`. Use the right type for the action — `spotify_playback.play` with a `context_uri` expects album/playlist/artist; `uris` expects an array of track URIs. + +## What NOT to do + +- **Don't call `get_state` before every action.** Spotify accepts play/pause/skip without preflight. Only inspect state when the user asked "what's playing" or you need to reason about device/track. +- **Don't describe search results unless asked.** If the user said "play X", search, grab the top URI, play it. They'll hear it's wrong if it's wrong. +- **Don't retry on `403 Premium required` or `403 No active device`.** Those are permanent until user action. +- **Don't use `spotify_search` to find a playlist by name** — that searches the public Spotify catalog. User playlists come from `spotify_playlists list`. +- **Don't mix `kind: "tracks"` with album URIs** in `spotify_library` (or vice versa). The tool normalizes IDs but the API endpoint differs. diff --git a/tests/tools/test_spotify_client.py b/tests/tools/test_spotify_client.py index 17157ec4e..61530f0aa 100644 --- a/tests/tools/test_spotify_client.py +++ b/tests/tools/test_spotify_client.py @@ -148,7 +148,7 @@ def test_get_currently_playing_returns_explanatory_empty_payload(monkeypatch: py } -def test_spotify_activity_now_playing_returns_explanatory_empty_result(monkeypatch: pytest.MonkeyPatch) -> None: +def test_spotify_playback_get_currently_playing_returns_explanatory_empty_result(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( spotify_tool, "_spotify_client", @@ -159,11 +159,11 @@ def test_spotify_activity_now_playing_returns_explanatory_empty_result(monkeypat }), ) - payload = json.loads(spotify_tool._handle_spotify_activity({"action": "now_playing"})) + payload = json.loads(spotify_tool._handle_spotify_playback({"action": "get_currently_playing"})) assert payload == { "success": True, - "action": "now_playing", + "action": "get_currently_playing", "is_playing": False, "status_code": 204, "message": "Spotify is not currently playing anything. Start playback in Spotify and try again.", @@ -242,3 +242,58 @@ def test_library_remove_uses_generic_library_endpoint( {"uris": ",".join(expected_uris)}, ) ] + + + +def test_spotify_library_tracks_list_routes_to_saved_tracks(monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[str] = [] + + class _LibStub: + def get_saved_tracks(self, **kw): + seen.append("tracks") + return {"items": [], "total": 0} + + def get_saved_albums(self, **kw): + seen.append("albums") + return {"items": [], "total": 0} + + monkeypatch.setattr(spotify_tool, "_spotify_client", lambda: _LibStub()) + json.loads(spotify_tool._handle_spotify_library({"kind": "tracks", "action": "list"})) + assert seen == ["tracks"] + + +def test_spotify_library_albums_list_routes_to_saved_albums(monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[str] = [] + + class _LibStub: + def get_saved_tracks(self, **kw): + seen.append("tracks") + return {"items": [], "total": 0} + + def get_saved_albums(self, **kw): + seen.append("albums") + return {"items": [], "total": 0} + + monkeypatch.setattr(spotify_tool, "_spotify_client", lambda: _LibStub()) + json.loads(spotify_tool._handle_spotify_library({"kind": "albums", "action": "list"})) + assert seen == ["albums"] + + +def test_spotify_library_rejects_missing_kind() -> None: + payload = json.loads(spotify_tool._handle_spotify_library({"action": "list"})) + assert "kind" in (payload.get("error") or "").lower() + + +def test_spotify_playback_recently_played_action(monkeypatch: pytest.MonkeyPatch) -> None: + """recently_played is now an action on spotify_playback (folded from spotify_activity).""" + seen: list[dict] = [] + + class _RecentStub: + def get_recently_played(self, **kw): + seen.append(kw) + return {"items": [{"track": {"name": "x"}}]} + + monkeypatch.setattr(spotify_tool, "_spotify_client", lambda: _RecentStub()) + payload = json.loads(spotify_tool._handle_spotify_playback({"action": "recently_played", "limit": 5})) + assert seen and seen[0]["limit"] == 5 + assert isinstance(payload, dict) diff --git a/tools/spotify_tool.py b/tools/spotify_tool.py index dc88953c9..b34b2b134 100644 --- a/tools/spotify_tool.py +++ b/tools/spotify_tool.py @@ -152,6 +152,16 @@ def _handle_spotify_playback(args: dict, **kw) -> str: return tool_error("volume_percent is required for action='set_volume'") result = client.set_volume(volume_percent=max(0, min(100, int(args["volume_percent"]))), device_id=args.get("device_id")) return tool_result({"success": True, "action": action, "result": result}) + if action == "recently_played": + after = args.get("after") + before = args.get("before") + if after and before: + return tool_error("Provide only one of 'after' or 'before'") + return tool_result(client.get_recently_played( + limit=_coerce_limit(args.get("limit"), default=20), + after=int(after) if after is not None else None, + before=int(before) if before is not None else None, + )) return tool_error(f"Unknown spotify_playback action: {action}") except Exception as exc: return _spotify_tool_error(exc) @@ -282,78 +292,33 @@ def _handle_spotify_albums(args: dict, **kw) -> str: return _spotify_tool_error(exc) -def _handle_spotify_saved_tracks(args: dict, **kw) -> str: +def _handle_spotify_library(args: dict, **kw) -> str: + """Unified handler for saved tracks + saved albums (formerly two tools).""" + kind = str(args.get("kind") or "").strip().lower() + if kind not in {"tracks", "albums"}: + return tool_error("kind must be one of: tracks, albums") action = str(args.get("action") or "list").strip().lower() + item_type = "track" if kind == "tracks" else "album" client = _spotify_client() try: if action == "list": - return tool_result(client.get_saved_tracks( - limit=_coerce_limit(args.get("limit"), default=20), - offset=max(0, int(args.get("offset") or 0)), - market=args.get("market"), - )) + limit = _coerce_limit(args.get("limit"), default=20) + offset = max(0, int(args.get("offset") or 0)) + market = args.get("market") + if kind == "tracks": + return tool_result(client.get_saved_tracks(limit=limit, offset=offset, market=market)) + return tool_result(client.get_saved_albums(limit=limit, offset=offset, market=market)) if action == "save": - uris = normalize_spotify_uris(_as_list(args.get("uris") or args.get("items")), "track") + uris = normalize_spotify_uris(_as_list(args.get("uris") or args.get("items")), item_type) return tool_result(client.save_library_items(uris=uris)) if action == "remove": - track_ids = [normalize_spotify_id(item, "track") for item in _as_list(args.get("ids") or args.get("items"))] - if not track_ids: + ids = [normalize_spotify_id(item, item_type) for item in _as_list(args.get("ids") or args.get("items"))] + if not ids: return tool_error("ids/items is required for action='remove'") - return tool_result(client.remove_saved_tracks(track_ids=track_ids)) - return tool_error(f"Unknown spotify_saved_tracks action: {action}") - except Exception as exc: - return _spotify_tool_error(exc) - - -def _handle_spotify_saved_albums(args: dict, **kw) -> str: - action = str(args.get("action") or "list").strip().lower() - client = _spotify_client() - try: - if action == "list": - return tool_result(client.get_saved_albums( - limit=_coerce_limit(args.get("limit"), default=20), - offset=max(0, int(args.get("offset") or 0)), - market=args.get("market"), - )) - if action == "save": - uris = normalize_spotify_uris(_as_list(args.get("uris") or args.get("items")), "album") - return tool_result(client.save_library_items(uris=uris)) - if action == "remove": - album_ids = [normalize_spotify_id(item, "album") for item in _as_list(args.get("ids") or args.get("items"))] - if not album_ids: - return tool_error("ids/items is required for action='remove'") - return tool_result(client.remove_saved_albums(album_ids=album_ids)) - return tool_error(f"Unknown spotify_saved_albums action: {action}") - except Exception as exc: - return _spotify_tool_error(exc) - - -def _handle_spotify_activity(args: dict, **kw) -> str: - action = str(args.get("action") or "now_playing").strip().lower() - client = _spotify_client() - try: - if action == "now_playing": - payload = client.get_currently_playing(market=args.get("market")) - if isinstance(payload, dict) and payload.get("empty"): - return tool_result({ - "success": True, - "action": action, - "is_playing": False, - "status_code": payload.get("status_code", 204), - "message": payload.get("message") or "Spotify is not currently playing anything.", - }) - return tool_result(payload) - if action == "recently_played": - after = args.get("after") - before = args.get("before") - if after and before: - return tool_error("Provide only one of 'after' or 'before'") - return tool_result(client.get_recently_played( - limit=_coerce_limit(args.get("limit"), default=20), - after=int(after) if after is not None else None, - before=int(before) if before is not None else None, - )) - return tool_error(f"Unknown spotify_activity action: {action}") + if kind == "tracks": + return tool_result(client.remove_saved_tracks(track_ids=ids)) + return tool_result(client.remove_saved_albums(album_ids=ids)) + return tool_error(f"Unknown spotify_library action: {action}") except Exception as exc: return _spotify_tool_error(exc) @@ -362,11 +327,11 @@ COMMON_STRING = {"type": "string"} SPOTIFY_PLAYBACK_SCHEMA = { "name": "spotify_playback", - "description": "Control Spotify playback or inspect the active playback state.", + "description": "Control Spotify playback, inspect the active playback state, or fetch recently played tracks.", "parameters": { "type": "object", "properties": { - "action": {"type": "string", "enum": ["get_state", "get_currently_playing", "play", "pause", "next", "previous", "seek", "set_repeat", "set_shuffle", "set_volume"]}, + "action": {"type": "string", "enum": ["get_state", "get_currently_playing", "play", "pause", "next", "previous", "seek", "set_repeat", "set_shuffle", "set_volume", "recently_played"]}, "device_id": COMMON_STRING, "market": COMMON_STRING, "context_uri": COMMON_STRING, @@ -375,6 +340,9 @@ SPOTIFY_PLAYBACK_SCHEMA = { "position_ms": {"type": "integer"}, "state": {"description": "For set_repeat use track/context/off. For set_shuffle use boolean-like true/false.", "oneOf": [{"type": "string"}, {"type": "boolean"}]}, "volume_percent": {"type": "integer"}, + "limit": {"type": "integer", "description": "For recently_played: number of tracks (max 50)"}, + "after": {"type": "integer", "description": "For recently_played: Unix ms cursor (after this timestamp)"}, + "before": {"type": "integer", "description": "For recently_played: Unix ms cursor (before this timestamp)"}, }, "required": ["action"], }, @@ -466,12 +434,13 @@ SPOTIFY_ALBUMS_SCHEMA = { }, } -SPOTIFY_SAVED_TRACKS_SCHEMA = { - "name": "spotify_saved_tracks", - "description": "List, save, or remove the user's saved Spotify tracks.", +SPOTIFY_LIBRARY_SCHEMA = { + "name": "spotify_library", + "description": "List, save, or remove the user's saved Spotify tracks or albums. Use `kind` to select which.", "parameters": { "type": "object", "properties": { + "kind": {"type": "string", "enum": ["tracks", "albums"], "description": "Which library to operate on"}, "action": {"type": "string", "enum": ["list", "save", "remove"]}, "limit": {"type": "integer"}, "offset": {"type": "integer"}, @@ -480,41 +449,7 @@ SPOTIFY_SAVED_TRACKS_SCHEMA = { "ids": {"type": "array", "items": COMMON_STRING}, "items": {"type": "array", "items": COMMON_STRING}, }, - "required": ["action"], - }, -} - -SPOTIFY_SAVED_ALBUMS_SCHEMA = { - "name": "spotify_saved_albums", - "description": "List, save, or remove the user's saved Spotify albums.", - "parameters": { - "type": "object", - "properties": { - "action": {"type": "string", "enum": ["list", "save", "remove"]}, - "limit": {"type": "integer"}, - "offset": {"type": "integer"}, - "market": COMMON_STRING, - "uris": {"type": "array", "items": COMMON_STRING}, - "ids": {"type": "array", "items": COMMON_STRING}, - "items": {"type": "array", "items": COMMON_STRING}, - }, - "required": ["action"], - }, -} - -SPOTIFY_ACTIVITY_SCHEMA = { - "name": "spotify_activity", - "description": "Inspect now playing or recently played Spotify activity.", - "parameters": { - "type": "object", - "properties": { - "action": {"type": "string", "enum": ["now_playing", "recently_played"]}, - "market": COMMON_STRING, - "limit": {"type": "integer"}, - "after": {"type": "integer"}, - "before": {"type": "integer"}, - }, - "required": ["action"], + "required": ["kind", "action"], }, } @@ -525,6 +460,4 @@ registry.register(name="spotify_queue", toolset="spotify", schema=SPOTIFY_QUEUE_ registry.register(name="spotify_search", toolset="spotify", schema=SPOTIFY_SEARCH_SCHEMA, handler=_handle_spotify_search, check_fn=_check_spotify_available, emoji="🔎") registry.register(name="spotify_playlists", toolset="spotify", schema=SPOTIFY_PLAYLISTS_SCHEMA, handler=_handle_spotify_playlists, check_fn=_check_spotify_available, emoji="📚") registry.register(name="spotify_albums", toolset="spotify", schema=SPOTIFY_ALBUMS_SCHEMA, handler=_handle_spotify_albums, check_fn=_check_spotify_available, emoji="💿") -registry.register(name="spotify_saved_tracks", toolset="spotify", schema=SPOTIFY_SAVED_TRACKS_SCHEMA, handler=_handle_spotify_saved_tracks, check_fn=_check_spotify_available, emoji="❤️") -registry.register(name="spotify_saved_albums", toolset="spotify", schema=SPOTIFY_SAVED_ALBUMS_SCHEMA, handler=_handle_spotify_saved_albums, check_fn=_check_spotify_available, emoji="💽") -registry.register(name="spotify_activity", toolset="spotify", schema=SPOTIFY_ACTIVITY_SCHEMA, handler=_handle_spotify_activity, check_fn=_check_spotify_available, emoji="🕘") +registry.register(name="spotify_library", toolset="spotify", schema=SPOTIFY_LIBRARY_SCHEMA, handler=_handle_spotify_library, check_fn=_check_spotify_available, emoji="❤️") diff --git a/toolsets.py b/toolsets.py index 38c51711b..65f560bfe 100644 --- a/toolsets.py +++ b/toolsets.py @@ -218,11 +218,10 @@ TOOLSETS = { }, "spotify": { - "description": "Native Spotify playback, search, playlist, album, library, and activity tools", + "description": "Native Spotify playback, search, playlist, album, and library tools", "tools": [ "spotify_playback", "spotify_devices", "spotify_queue", "spotify_search", - "spotify_playlists", "spotify_albums", "spotify_saved_tracks", - "spotify_saved_albums", "spotify_activity", + "spotify_playlists", "spotify_albums", "spotify_library", ], "includes": [] }, diff --git a/website/docs/user-guide/features/spotify.md b/website/docs/user-guide/features/spotify.md index 298bd0642..de97ca3a1 100644 --- a/website/docs/user-guide/features/spotify.md +++ b/website/docs/user-guide/features/spotify.md @@ -64,7 +64,7 @@ Shows whether tokens are present and when the access token expires. Refresh is a ## Using it -Once logged in, the agent has access to 9 Spotify tools. You talk to the agent naturally — it picks the right tool and action. +Once logged in, the agent has access to 7 Spotify tools. You talk to the agent naturally — it picks the right tool and action. For the best behavior, the agent loads a companion skill that teaches canonical usage patterns (single-search-then-play, when not to preflight `get_state`, etc.). ``` > play some miles davis @@ -82,12 +82,12 @@ Once logged in, the agent has access to 9 Spotify tools. You talk to the agent n All playback-mutating actions accept an optional `device_id` to target a specific device. If omitted, Spotify uses the currently active device. #### `spotify_playback` -Control and inspect playback. +Control and inspect playback, plus fetch recently played history. | Action | Purpose | Premium? | |--------|---------|----------| | `get_state` | Full playback state (track, device, progress, shuffle/repeat) | No | -| `get_currently_playing` | Just the current track | No | +| `get_currently_playing` | Just the current track (returns empty on 204 — see below) | No | | `play` | Start/resume playback. Optional: `context_uri`, `uris`, `offset`, `position_ms` | Yes | | `pause` | Pause playback | Yes | | `next` / `previous` | Skip track | Yes | @@ -95,6 +95,7 @@ Control and inspect playback. | `set_repeat` | `state` = `track` / `context` / `off` | Yes | | `set_shuffle` | `state` = `true` / `false` | Yes | | `set_volume` | `volume_percent` = 0-100 | Yes | +| `recently_played` | Last played tracks. Optional `limit`, `before`, `after` (Unix ms) | No | #### `spotify_devices` | Action | Purpose | @@ -127,18 +128,16 @@ Search the catalog. `query` is required. Optional: `types` (array of `track` / ` | `get` | Album metadata | `album_id` | | `tracks` | Album track list | `album_id` | -#### `spotify_saved_tracks` / `spotify_saved_albums` +#### `spotify_library` +Unified access to saved tracks and saved albums. Pick the collection with the `kind` arg. + | Action | Purpose | |--------|---------| | `list` | Paginated library listing | | `save` | Add `ids` / `uris` to library | | `remove` | Remove `ids` / `uris` from library | -#### `spotify_activity` -| Action | Purpose | Premium? | -|--------|---------|----------| -| `now_playing` | Currently playing (returns empty on 204 — see below) | No | -| `recently_played` | Last played tracks. Optional `limit`, `before`, `after` (Unix ms) | No | +Required: `kind` = `tracks` or `albums`, plus `action`. ### Feature matrix: Free vs Premium @@ -147,14 +146,12 @@ Read-only tools work on Free accounts. Anything that mutates playback or the que | Works on Free | Premium required | |---------------|------------------| | `spotify_search` (all) | `spotify_playback` — play, pause, next, previous, seek, set_repeat, set_shuffle, set_volume | -| `spotify_playback` — get_state, get_currently_playing | `spotify_queue` — add | +| `spotify_playback` — get_state, get_currently_playing, recently_played | `spotify_queue` — add | | `spotify_devices` — list | `spotify_devices` — transfer | | `spotify_queue` — get | | | `spotify_playlists` (all) | | | `spotify_albums` (all) | | -| `spotify_saved_tracks` (all) | | -| `spotify_saved_albums` (all) | | -| `spotify_activity` (all) | | +| `spotify_library` (all) | | ## Sign out @@ -172,7 +169,7 @@ To revoke the app on Spotify's side, visit [Apps connected to your account](http **`403 Forbidden — Premium required`** — You're on a Free account trying to use a playback-mutating action. See the feature matrix above. -**`204 No Content` on `now_playing`** — nothing is currently playing on any device. This is Spotify's normal response, not an error; Hermes surfaces it as an explanatory empty result. +**`204 No Content` on `get_currently_playing`** — nothing is currently playing on any device. This is Spotify's normal response, not an error; Hermes surfaces it as an explanatory empty result (`is_playing: false`). **`INVALID_CLIENT: Invalid redirect URI`** — the redirect URI in your Spotify app settings doesn't match what Hermes is using. The default is `http://127.0.0.1:43827/spotify/callback`. Either add that to your app's allowed redirect URIs, or set `HERMES_SPOTIFY_REDIRECT_URI` in `~/.hermes/.env` to whatever you registered.