From 9c32d8e44ced68a7d76ee400548a859cb0ef1d0f Mon Sep 17 00:00:00 2001 From: Moritz Graf Date: Fri, 11 Sep 2026 21:03:54 +0200 Subject: [PATCH] feat(spotify): add spotify skill and oauth generator for openclaw --- AGENTS.md | 6 + skills/spotify/SKILL.md | 79 +++++++++ skills/spotify/scripts/spotify_auth.py | 224 +++++++++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 skills/spotify/SKILL.md create mode 100755 skills/spotify/scripts/spotify_auth.py diff --git a/AGENTS.md b/AGENTS.md index 266a97a..797b22f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,12 @@ Smart shopping advisor and Vorrats-Concierge. - **Features:** German supermarket & discounter weekly discounts (Edeka, Rewe, Aldi, Lidl, Kaufland, Netto, Penny, dm, Rossmann), Vorratskauf multi-brand comparison (Mutti / Oro di Parma), diaper piece-price calculator (ct/Windel), Geizhals price comparison & all-time-low price history, and eBay marketplace search. - **Tools:** Standalone MCP servers and CLIs at `scripts/supermarket_deals.py`, `scripts/geizhals_mcp.py`, and `scripts/ebay_mcp.py`. +### [spotify](skills/spotify/SKILL.md) +Spotify music intelligence, catalog search, playback control, playlist curation, party queueing, and personal listening history. +- **Features:** 35+ tools via `mcp-server-spotify` (search, playlists, player/queue, listening stats, top items). +- **Synergy:** Bridges Spotify search with Home Assistant to stream Spotify URIs directly on Sonos multi-room speakers and sync Philips Hue lighting. +- **Tools:** Includes one-time token generator `scripts/spotify_auth.py`. + ### [skill-creator](skills/skill-creator/SKILL.md) Guidelines and scripts to help developers build high-quality, token-efficient Claude skills. diff --git a/skills/spotify/SKILL.md b/skills/spotify/SKILL.md new file mode 100644 index 0000000..b89e390 --- /dev/null +++ b/skills/spotify/SKILL.md @@ -0,0 +1,79 @@ +--- +name: spotify +description: Full Spotify music intelligence, catalog search, playback control, playlist curation, party queueing, and personal listening history. Use when the user asks to search for music/podcasts/audiobooks, control Spotify playback, queue tracks, check what is currently playing, create or curate playlists (including collaborative mixes for friends), or inspect listening history and top tracks. +--- + +# ๐ŸŽต Spotify Skill for OpenClaw + +Full integration with the **Spotify Web API** powered by `mcp-server-spotify`. + +## ๐Ÿ› ๏ธ MCP Tools & Capabilities + +You have access to 35+ dedicated Spotify MCP tools: + +### 1. Catalog Search +- `search(query, types, limit)`: Search for tracks, artists, albums, playlists, shows, episodes, or audiobooks. + - `types`: array containing `"track"`, `"artist"`, `"album"`, `"playlist"`, `"show"`, `"episode"`, `"audiobook"`. + - Returns Spotify URIs (`spotify:track:...`), titles, artist names, album info, and web URLs (`https://open.spotify.com/...`). + +### 2. Playback & Device Control +- `get_playback_state()`: Returns current player status, active device, shuffle/repeat states, and track progress. +- `get_currently_playing()`: Quick status of the currently playing track or episode. +- `play(device_id, context_uri, uris, position_ms)`: Start or resume playback. Can play a specific playlist/album URI (`context_uri`) or a list of track URIs (`uris`). +- `pause(device_id)`: Pause active playback. +- `next_track(device_id)` / `previous_track(device_id)`: Skip tracks. +- `seek(position_ms, device_id)`: Seek to a position in milliseconds. +- `set_volume(volume_percent, device_id)`: Set volume (0-100). +- `toggle_shuffle(state, device_id)`: Enable or disable shuffle. +- `transfer_playback(device_ids, play)`: Transfer active playback to another Spotify Connect device. +- `get_devices()`: List available Spotify Connect devices (TVs, laptops, speakers, mobile). +- `add_to_queue(uri, device_id)`: Add a track/episode URI to the user's active playback queue. +- `get_queue()`: Inspect currently playing item and upcoming queued items. + +### 3. Playlists & Collaboration +- `create_playlist(name, description, public)`: Create a new Spotify playlist for the user. +- `get_playlist(playlist_id)`: Retrieve playlist metadata and owner. +- `get_playlist_items(playlist_id, limit, offset)`: List tracks inside a playlist. +- `add_playlist_items(playlist_id, uris, position)`: Add track URIs to an existing playlist (supports batch addition). +- `remove_playlist_items(playlist_id, uris)`: Remove tracks from a playlist. +- `reorder_playlist_items(playlist_id, range_start, insert_before)`: Reorder songs in a playlist. +- `get_my_playlists(limit, offset)`: List current user's saved and created playlists. + +### 4. Personal History, Taste & Social +- `get_recently_played(limit)`: History of recently played tracks with timestamps. +- `get_my_top_items(type, time_range, limit)`: Fetch top tracks or top artists over short (`"short_term"` = 4 weeks), medium (`"medium_term"` = 6 months), or long term (`"long_term"` = years). +- `get_my_profile()`: User's profile details and country. +- `whoami()`: Diagnostic helper returning authentication state, active device, and granted scopes. + +--- + +## ๐Ÿ“ฑ Telegram Presentation Guidelines + +When formatting Spotify results or music recommendations for Telegram: +- **Clean Markdown Links**: Always format track and playlist links nicely: + - `โ–ถ๏ธ [Track Title - Artist Name](https://open.spotify.com/track/...)` + - `๐Ÿ“ [Playlist Name](https://open.spotify.com/playlist/...)` +- **Current Playing Card**: When asked *"what's playing?"*: + - Display: `๐ŸŽถ *Track Title* by *Artist*\n๐Ÿ’ฟ *Album Title* (Year)\nโฑ๏ธ mm:ss / mm:ss [โ– โ– โ– โ–กโ–กโ–กโ–กโ–กโ–กโ–ก]\n๐Ÿ”— [Open in Spotify](url)` +- **Friend Requests / Party Aux DJ**: + - When a friend in a Telegram group asks to add a song: + 1. Search Spotify for the best match via `search`. + 2. Add to active queue (`add_to_queue`) or the group's party playlist (`add_playlist_items`). + 3. Confirm with a friendly message: *"Added ๐ŸŽต [Song - Artist](url) to the queue! Next up."* + +--- + +## ๐Ÿก Home Assistant (Sonos & Hue) Synergy + +OpenClaw has access to both **Spotify MCP** and **Home Assistant MCP (`ha-mcp`)**: +- **Spotify MCP is the Music Brain**: Use it to search the global catalog, find Spotify URIs, build playlists, and inspect listening history. +- **Home Assistant is the Physical Hardware Controller**: + - Home Assistant's `media_player.play_media` requires a Spotify URI (`spotify:track:...`, `spotify:playlist:...`). + - When asked to *"Play X in the living room / on Sonos"*: + 1. Use `search` from Spotify MCP to get the exact `spotify:track:...` or `spotify:playlist:...` URI. + 2. Call `ha-mcp:ha_call_service`: + - Domain: `media_player` + - Service: `play_media` + - Service Data: `{"media_content_id": "", "media_content_type": "music"}` + - Target: `{"entity_id": "media_player.sonos_..."}` + 3. If asked for ambience or party lighting, call `ha-mcp:ha_call_service` on `light.turn_on` or activate Hue scenes simultaneously. diff --git a/skills/spotify/scripts/spotify_auth.py b/skills/spotify/scripts/spotify_auth.py new file mode 100755 index 0000000..f785058 --- /dev/null +++ b/skills/spotify/scripts/spotify_auth.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Spotify OAuth Helper for OpenClaw +Generates a permanent refresh_token for OpenClaw's Spotify MCP Server. + +Usage: + python3 spotify_auth.py --client-id --client-secret +Or simply: + python3 spotify_auth.py +""" + +import argparse +import base64 +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer + +DEFAULT_PORT = 8888 +DEFAULT_REDIRECT_URI = f"http://127.0.0.1:{DEFAULT_PORT}/callback" + +SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize" +SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token" +SPOTIFY_ME_URL = "https://api.spotify.com/v1/me" + +SCOPES = [ + # User profile & account + "user-read-private", + "user-read-email", + # Playback & Devices + "user-read-playback-state", + "user-modify-playback-state", + "user-read-currently-playing", + "user-read-recently-played", + # Library + "user-library-read", + "user-library-modify", + # Top stats & history + "user-top-read", + "user-read-playback-position", + # Playlists + "playlist-read-private", + "playlist-read-collaborative", + "playlist-modify-public", + "playlist-modify-private", + # Follow + "user-follow-read", + "user-follow-modify", +] + + +class OAuthCallbackHandler(BaseHTTPRequestHandler): + auth_code = None + error = None + + def do_GET(self): + parsed_url = urllib.parse.urlparse(self.path) + if parsed_url.path == "/callback": + params = urllib.parse.parse_qs(parsed_url.query) + if "code" in params: + OAuthCallbackHandler.auth_code = params["code"][0] + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + html = """ + + + + Spotify Auth Success + + + +
+

โœ… Authentication Successful!

+

OpenClaw has successfully connected to your Spotify account.

+

You can now close this browser window and check your terminal.

+
+ + + """ + self.wfile.write(html.encode("utf-8")) + else: + err_msg = params.get("error", ["Unknown error"])[0] + OAuthCallbackHandler.error = err_msg + self.send_response(400) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(f"

Authentication Failed

{err_msg}

".encode("utf-8")) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + # Silence standard HTTP access logging + pass + + +def exchange_code_for_tokens(client_id: str, client_secret: str, code: str, redirect_uri: str) -> dict: + auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + data = urllib.parse.urlencode({ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + }).encode("utf-8") + + req = urllib.request.Request( + SPOTIFY_TOKEN_URL, + data=data, + headers={ + "Authorization": f"Basic {auth_header}", + "Content-Type": "application/x-www-form-urlencoded", + }, + method="POST" + ) + + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def fetch_user_profile(access_token: str) -> dict: + req = urllib.request.Request( + SPOTIFY_ME_URL, + headers={"Authorization": f"Bearer {access_token}"} + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def main(): + parser = argparse.ArgumentParser(description="Spotify OAuth Helper for OpenClaw") + parser.add_argument("--client-id", help="Spotify Developer App Client ID") + parser.add_argument("--client-secret", help="Spotify Developer App Client Secret") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Local callback port (default: {DEFAULT_PORT})") + args = parser.parse_args() + + print("\n๐ŸŽต ===========================================") + print(" OpenClaw Spotify OAuth Token Generator") + print("===========================================\n") + + client_id = args.client_id or os.environ.get("SPOTIFY_CLIENT_ID") + client_secret = args.client_secret or os.environ.get("SPOTIFY_CLIENT_SECRET") + + if not client_id: + client_id = input("Enter Spotify Client ID: ").strip() + if not client_secret: + client_secret = input("Enter Spotify Client Secret: ").strip() + + if not client_id or not client_secret: + print("โŒ Error: Client ID and Client Secret are required.", file=sys.stderr) + sys.exit(1) + + redirect_uri = f"http://127.0.0.1:{args.port}/callback" + + # Start local HTTP server for OAuth callback + try: + server = HTTPServer(("127.0.0.1", args.port), OAuthCallbackHandler) + except OSError as e: + print(f"โŒ Error: Port {args.port} is already in use. Try specifying another port with --port.", file=sys.stderr) + sys.exit(1) + + # Build auth URL + auth_params = { + "client_id": client_id, + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": " ".join(SCOPES), + "show_dialog": "true", + } + auth_url = f"{SPOTIFY_AUTH_URL}?{urllib.parse.urlencode(auth_params)}" + + print(f"\n๐ŸŒ Opening browser to authorize Spotify account...") + print(f"If the browser doesn't open automatically, visit this URL:\n\n{auth_url}\n") + webbrowser.open(auth_url) + + print(f"Waiting for authorization on {redirect_uri} ...") + while OAuthCallbackHandler.auth_code is None and OAuthCallbackHandler.error is None: + server.handle_request() + + if OAuthCallbackHandler.error: + print(f"\nโŒ Authorization rejected or failed: {OAuthCallbackHandler.error}", file=sys.stderr) + sys.exit(1) + + code = OAuthCallbackHandler.auth_code + print("๐Ÿ”„ Exchanging authorization code for permanent tokens...") + + try: + token_data = exchange_code_for_tokens(client_id, client_secret, code, redirect_uri) + except urllib.error.HTTPError as e: + print(f"\nโŒ Failed to exchange code for tokens: {e.read().decode('utf-8')}", file=sys.stderr) + sys.exit(1) + + access_token = token_data.get("access_token") + refresh_token = token_data.get("refresh_token") + + # Fetch user profile to display confirmation + user_name = "Spotify User" + try: + profile = fetch_user_profile(access_token) + user_name = f"{profile.get('display_name', 'User')} ({profile.get('id', '')})" + except Exception: + pass + + print("\n๐ŸŽ‰ ===================================================") + print(f" Successfully Authenticated as: {user_name}") + print("===================================================\n") + print("Add these lines to your k8s/openclaw/openclaw.secret.yaml:") + print("---------------------------------------------------") + print(f" spotify-client-id: \"{client_id}\"") + print(f" spotify-client-secret: \"{client_secret}\"") + print(f" spotify-refresh-token: \"{refresh_token}\"") + print("---------------------------------------------------\n") + + +if __name__ == "__main__": + main()