spotify-controller
Control Spotify playback and devices from an AI agent using spotify.py and the official Spotify Web API. Use when users ask to check current track, play/pause, next/prev, set volume, search tracks, play first search result, list devices, switch active device, or play a specific Spotify URL. Works on headless VPS and Docker setups.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install openclaw-skills-spotify-controller
Repository
Skill path: skills/egemenyerdelen/spotify-controller
Control Spotify playback and devices from an AI agent using spotify.py and the official Spotify Web API. Use when users ask to check current track, play/pause, next/prev, set volume, search tracks, play first search result, list devices, switch active device, or play a specific Spotify URL. Works on headless VPS and Docker setups.
Open repositoryBest for
Primary workflow: Analyze Data & AI.
Technical facets: Full Stack, Backend, DevOps, Data / AI.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: openclaw.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install spotify-controller into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/openclaw/skills before adding spotify-controller to shared team environments
- Use spotify-controller for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: spotify-controller
description: Control Spotify playback and devices from an AI agent using spotify.py and the official Spotify Web API. Use when users ask to check current track, play/pause, next/prev, set volume, search tracks, play first search result, list devices, switch active device, or play a specific Spotify URL. Works on headless VPS and Docker setups.
homepage: https://developer.spotify.com
metadata: {"clawdbot":{"emoji":"🎵","requires":{"env":["SPOTIFY_CLIENT_ID","SPOTIFY_CLIENT_SECRET","SPOTIFY_REFRESH_TOKEN"],"bins":["python3"]},"primaryEnv":"SPOTIFY_CLIENT_ID"}}
---
# Spotify Controller Skill
Control Spotify playback from your AI agent using the official Spotify Web API.
This works across setups (local machine, Docker, VPS, and hybrid environments). It is especially useful for fixing Spotify control pain in headless VPS deployments. The server does **not** need a browser or a local Spotify client.
---
## What this skill provides
- A CLI workflow around `spotify.py`
- Playback control (`play`, `pause`, `next`, `prev`)
- Track lookup (`search`) and quick play (`playsearch`)
- Direct URI playback (`playtrack spotify:track:...`)
- Device management (`devices`, `setdevice`)
- Volume control (`volume 0-100`, where supported)
---
## Requirements
- Python 3 available in runtime/container
- `requests` package installed
- Spotify Premium account
- Spotify Developer app credentials
- Environment variables:
- `SPOTIFY_CLIENT_ID`
- `SPOTIFY_CLIENT_SECRET`
- `SPOTIFY_REFRESH_TOKEN`
Install dependency:
```bash
uv pip install requests --system
```
(Alternative: `pip install requests`)
If you build OpenClaw in Docker, add this to your `Dockerfile` when `requests` is not already present:
```dockerfile
RUN uv pip install requests --system
```
---
## Setup (Step-by-step)
### 1) Create a Spotify Developer App
1. Go to: https://developer.spotify.com/dashboard
2. Click **Create App**
3. Enter any app name/description
4. Add Redirect URI:
- `http://127.0.0.1:8888/callback`
5. Enable **Web API** access
6. Save and copy:
- **Client ID**
- **Client Secret**
### 2) Get a refresh token (one-time, on local machine)
Open this URL in your browser (replace `YOUR_CLIENT_ID`):
```text
https://accounts.spotify.com/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://127.0.0.1:8888/callback&scope=user-modify-playback-state%20user-read-playback-state%20user-read-currently-playing
```
Approve access, then copy the `code` value from the redirected URL.
Exchange code for tokens:
```bash
curl -s -X POST "https://accounts.spotify.com/api/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&code=YOUR_CODE&redirect_uri=http://127.0.0.1:8888/callback&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET"
```
From response JSON, copy `refresh_token`.
> `refresh_token` is typically long-lived, but can be invalidated if app access is revoked, app settings change, or credentials rotate.
### 3) Add credentials to `.env`
```env
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
SPOTIFY_REFRESH_TOKEN=your_refresh_token
```
### 4) Pass env vars to Docker compose service
In `docker-compose.yml` service `environment:` section:
```yaml
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
- SPOTIFY_REFRESH_TOKEN=${SPOTIFY_REFRESH_TOKEN}
```
### 5) Restart service/container
```bash
docker compose down
docker compose up -d openclaw-gateway
```
```bash
chown <runtime_user>:<runtime_group> /path/to/workspace/spotify.py
chmod 664 /path/to/workspace/spotify.py
```
---
## Usage
Run commands from workspace:
```bash
python3 spotify.py <command>
```
| Command | Description |
|--------------------------------------------------|---|
| `python3 spotify.py status` | Show current playback state and track |
| `python3 spotify.py play` | Resume playback |
| `python3 spotify.py pause` | Pause playback |
| `python3 spotify.py next` | Skip to next track |
| `python3 spotify.py prev` | Go to previous track |
| `python3 spotify.py volume 80` | Set volume (0–100) where supported |
| `python3 spotify.py search track` | Search tracks (top results) |
| `python3 spotify.py playsearch "track"` | Search and play first result |
| `python3 spotify.py playtrack spotify:track:URI` | Play specific track URI |
| `python3 spotify.py devices` | List available Spotify devices |
| `python3 spotify.py setdevice "BEDROOM-SPEAKER"` | Set active device by name or id |
---
## VPS / Headless behavior notes
- Headless server control works because playback is executed on Spotify Connect devices (phone/desktop/web), not the server audio output.
- You still need at least one **active Spotify device session**.
If you see `NO_ACTIVE_DEVICE`:
1. Open Spotify on target device
2. Start any track manually once
3. Run `python3 spotify.py devices`
4. Retry command
---
## Known Spotify API limitations (expected)
- Some devices/content contexts may return `403 Restriction violated` for `play`, `prev`, or other controls.
- Some devices may reject remote volume changes (`VOLUME_CONTROL_DISALLOW`).
- Device handoff can lag; immediate `status` after transfer may briefly show stale state.
These are Spotify-side constraints, not necessarily script bugs.
---
## Operational guidance for automations
- Treat non-zero exit codes as command failures.
- Validate environment vars at startup.
- Log command + status code for troubleshooting.
- Retry once for transient network errors.
- Don’t hardcode real credentials in files.
---
## Security notes
- Never commit `.env` with live secrets.
- Rotate app credentials if leaked.
- Use least-access scopes required for your workflow.
- The script only communicates with accounts.spotify.com and api.spotify.com
---
## Quick troubleshooting checklist
1. `python3 spotify.py devices` shows your target device?
2. Device is active in Spotify app?
3. Env vars loaded inside container/runtime?
4. Premium account confirmed?
5. Refresh token still valid?
6. Any Spotify 403 restriction reason in response?
---
## Claim accuracy
- Uses official Spotify Web API ✅
- Works on headless VPS ✅
- Practical for personal usage ✅
- Subject to normal Spotify API behavior/limits ✅
---
## Skill Companion Files
> Additional files collected from the skill directory layout.
### _meta.json
```json
{
"owner": "egemenyerdelen",
"slug": "spotify-controller",
"displayName": "Spotify Controller",
"latest": {
"version": "1.0.1",
"publishedAt": 1772155834421,
"commit": "https://github.com/openclaw/skills/commit/b7f073fa25627adc7850e9e2dd53ee73abfc4772"
},
"history": []
}
```
### scripts/spotify.py
```python
#!/usr/bin/env python3
"""Spotify controller.
Usage:
python3 spotify.py status
python3 spotify.py play|pause|next|prev
python3 spotify.py volume <0-100>
python3 spotify.py search <query>
python3 spotify.py playtrack <spotify:track:...>
python3 spotify.py playsearch <query>
python3 spotify.py devices
python3 spotify.py setdevice <device_id_or_exact_name>
"""
import os
import sys
from urllib.parse import quote
import requests
CLIENT_ID = os.environ.get("SPOTIFY_CLIENT_ID")
CLIENT_SECRET = os.environ.get("SPOTIFY_CLIENT_SECRET")
REFRESH_TOKEN = os.environ.get("SPOTIFY_REFRESH_TOKEN")
def fail(msg: str, code: int = 1):
print(f"❌ {msg}")
raise SystemExit(code)
def ensure_env():
missing = [
name
for name, value in {
"SPOTIFY_CLIENT_ID": CLIENT_ID,
"SPOTIFY_CLIENT_SECRET": CLIENT_SECRET,
"SPOTIFY_REFRESH_TOKEN": REFRESH_TOKEN,
}.items()
if not value
]
if missing:
fail(f"Missing env vars: {', '.join(missing)}")
def get_access_token() -> str:
ensure_env()
r = requests.post(
"https://accounts.spotify.com/api/token",
data={
"grant_type": "refresh_token",
"refresh_token": REFRESH_TOKEN,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
timeout=20,
)
if r.status_code != 200:
fail(f"Token refresh failed ({r.status_code}): {r.text[:200]}")
token = r.json().get("access_token")
if not token:
fail("Token response missing access_token")
return token
def spotify(method: str, endpoint: str, **kwargs) -> requests.Response:
token = get_access_token()
headers = kwargs.pop("headers", {})
headers["Authorization"] = f"Bearer {token}"
url = f"https://api.spotify.com/v1{endpoint}"
return requests.request(method, url, headers=headers, timeout=20, **kwargs)
def print_active_device_hint():
print("💡 Hint: Open Spotify on phone/desktop/web and start any track once to activate a device.")
print(" Then run: python3 spotify.py devices")
def print_player_state(resp: requests.Response):
if resp.status_code == 204:
print("Nothing playing (or no active device)")
print_active_device_hint()
return
if resp.status_code != 200:
print(f"Player status error ({resp.status_code}): {resp.text[:200]}")
return
d = resp.json()
item = d.get("item")
if not item:
print("No track info available")
return
artists = item.get("artists") or []
artist = artists[0].get("name") if artists else "Unknown artist"
icon = "▶" if d.get("is_playing") else "⏸"
print(f"{icon} {item.get('name', 'Unknown track')} — {artist}")
def require_arg(index: int, message: str) -> str:
if len(sys.argv) <= index:
fail(message)
return sys.argv[index]
def usage():
print(__doc__.strip())
def print_api_error(prefix: str, r: requests.Response):
print(f"{prefix} ({r.status_code}): {r.text[:200]}")
if r.status_code == 404:
print_active_device_hint()
if r.status_code == 403 and "VOLUME_CONTROL_DISALLOW" in r.text:
print("💡 This device does not allow remote volume control via Spotify API.")
def get_search_results(query: str, limit: int = 5):
q = quote(query)
r = spotify("GET", f"/search?q={q}&type=track&limit={limit}")
if r.status_code != 200:
fail(f"Search error ({r.status_code}): {r.text[:200]}")
return (r.json().get("tracks") or {}).get("items") or []
def get_devices():
r = spotify("GET", "/me/player/devices")
if r.status_code != 200:
fail(f"Devices error ({r.status_code}): {r.text[:200]}")
return r.json().get("devices") or []
def cmd_status():
r = spotify("GET", "/me/player")
print_player_state(r)
def cmd_play_pause_skip(endpoint: str, ok_text: str, err_prefix: str):
r = spotify("PUT" if endpoint in ("/me/player/play", "/me/player/pause") else "POST", endpoint)
if 200 <= r.status_code < 300:
print(ok_text)
else:
print_api_error(err_prefix, r)
raise SystemExit(1)
def cmd_volume():
vol_raw = require_arg(2, "Usage: python3 spotify.py volume <0-100>")
try:
vol = int(vol_raw)
except ValueError:
fail("Volume must be an integer 0-100")
if not 0 <= vol <= 100:
fail("Volume must be between 0 and 100")
r = spotify("PUT", f"/me/player/volume?volume_percent={vol}")
if 200 <= r.status_code < 300:
print(f"🔊 Volume set to {vol}%")
else:
print_api_error("Volume error", r)
raise SystemExit(1)
def cmd_search():
query = " ".join(sys.argv[2:]).strip()
if not query:
fail("Usage: python3 spotify.py search <query>")
items = get_search_results(query, limit=5)
if not items:
print("No results")
return
for t in items:
artists = t.get("artists") or []
artist = artists[0].get("name") if artists else "Unknown artist"
print(f"{t.get('name', 'Unknown track')} — {artist} | {t.get('uri', '-')}")
def cmd_playtrack(uri: str):
r = spotify("PUT", "/me/player/play", json={"uris": [uri]})
if 200 <= r.status_code < 300:
print(f"▶ Playing {uri}")
else:
print_api_error("Playtrack error", r)
raise SystemExit(1)
def cmd_playsearch():
query = " ".join(sys.argv[2:]).strip()
if not query:
fail("Usage: python3 spotify.py playsearch <query>")
items = get_search_results(query, limit=1)
if not items:
print("No results")
return
t = items[0]
uri = t.get("uri")
artists = t.get("artists") or []
artist = artists[0].get("name") if artists else "Unknown artist"
name = t.get("name", "Unknown track")
if not uri:
fail("Search returned track without URI")
r = spotify("PUT", "/me/player/play", json={"uris": [uri]})
if 200 <= r.status_code < 300:
print(f"▶ Playing {name} — {artist}")
else:
print_api_error("Playsearch error", r)
raise SystemExit(1)
def cmd_devices():
devices = get_devices()
if not devices:
print("No Spotify devices found")
print_active_device_hint()
return
for d in devices:
active = "*" if d.get("is_active") else " "
name = d.get("name", "Unknown")
dev_id = d.get("id", "-")
typ = d.get("type", "?")
vol = d.get("volume_percent")
print(f"{active} {name} [{typ}] id={dev_id} vol={vol}%")
def cmd_setdevice(target: str):
devices = get_devices()
if not devices:
fail("No devices available to set")
chosen = None
# Exact ID first
for d in devices:
if d.get("id") == target:
chosen = d
break
# Exact (case-insensitive) name fallback
if not chosen:
for d in devices:
if (d.get("name") or "").lower() == target.lower():
chosen = d
break
# Partial name fallback if unique
if not chosen:
matches = [d for d in devices if target.lower() in (d.get("name") or "").lower()]
if len(matches) == 1:
chosen = matches[0]
elif len(matches) > 1:
print("Multiple device matches found:")
for d in matches:
print(f"- {d.get('name')} (id={d.get('id')})")
fail("Please provide exact name or id")
if not chosen or not chosen.get("id"):
fail("Device not found. Run: python3 spotify.py devices")
r = spotify("PUT", "/me/player", json={"device_ids": [chosen["id"]], "play": False})
if 200 <= r.status_code < 300:
print(f"✅ Active device set to: {chosen.get('name')}")
else:
print_api_error("Setdevice error", r)
raise SystemExit(1)
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
if cmd in {"-h", "--help", "help"}:
usage()
raise SystemExit(0)
if cmd == "status":
cmd_status()
elif cmd == "play":
cmd_play_pause_skip("/me/player/play", "▶ Playing", "Play error")
elif cmd == "pause":
cmd_play_pause_skip("/me/player/pause", "⏸ Paused", "Pause error")
elif cmd == "next":
cmd_play_pause_skip("/me/player/next", "⏭ Skipped", "Next error")
elif cmd == "prev":
cmd_play_pause_skip("/me/player/previous", "⏮ Previous", "Previous error")
elif cmd == "volume":
cmd_volume()
elif cmd == "search":
cmd_search()
elif cmd == "playtrack":
cmd_playtrack(require_arg(2, "Usage: python3 spotify.py playtrack <spotify:track:...>"))
elif cmd == "playsearch":
cmd_playsearch()
elif cmd == "devices":
cmd_devices()
elif cmd == "setdevice":
cmd_setdevice(require_arg(2, "Usage: python3 spotify.py setdevice <device_id_or_exact_name>"))
else:
usage()
fail(f"Unknown command: {cmd}")
```