Overview
Create a Spotify App & Add a Redirect URI
Go to the Spotify Developer Dashboard → create an app. https://developer.spotify.com/dashboard
In the app Settings, add a Redirect URI you can receive in a browser, http://127.0.0.1:8888/callback, then Save.
- Record the Client ID and Client Secret.
Get Refresh Token
Run the following script using the following parameters:
SPOTIFY_CLIENT_ID=... SPOTIFY_CLIENT_SECRET=... ./get-spotify-refresh-token.sh
get-spotify-refresh-token.sh
#!/usr/bin/env bash # get-spotify-refresh-token.sh # One-time helper to obtain a Spotify REFRESH TOKEN for the Web API. # # Usage (env vars OR flags): # SPOTIFY_CLIENT_ID=... SPOTIFY_CLIENT_SECRET=... ./get-spotify-refresh-token.sh # ./get-spotify-refresh-token.sh --client-id=... --client-secret=... # # Options: # --client-id=ID # --client-secret=SECRET # --redirect-uri=URI (default: http://127.0.0.1:8888/callback) # --scopes="space separated scopes" # --pkce (use Authorization Code with PKCE; no client secret needed) # # Output on success: # export SPOTIFY_CLIENT_ID="..." # export SPOTIFY_CLIENT_SECRET="..." # omitted in --pkce mode # export SPOTIFY_REFRESH_TOKEN="..." # # Requirements: bash, curl, jq, python3. (openssl recommended) set -euo pipefail # -------- defaults -------- CLIENT_ID="${SPOTIFY_CLIENT_ID:-}" CLIENT_SECRET="${SPOTIFY_CLIENT_SECRET:-}" REDIRECT_URI_DEFAULT="http://127.0.0.1:8888/callback" REDIRECT_URI="${SPOTIFY_REDIRECT_URI:-$REDIRECT_URI_DEFAULT}" SCOPES="${SPOTIFY_SCOPES:-user-modify-playback-state user-read-playback-state}" USE_PKCE="false" # -------- args -------- for arg in "$@"; do case "$arg" in --client-id=*) CLIENT_ID="${arg#*=}";; --client-secret=*) CLIENT_SECRET="${arg#*=}";; --redirect-uri=*) REDIRECT_URI="${arg#*=}";; --scopes=*) SCOPES="${arg#*=}";; --pkce) USE_PKCE="true";; --help|-h) sed -n '1,60p' "$0"; exit 0;; *) echo "Unknown option: $arg" >&2; exit 2;; esac done need() { if [[ -z "${!1:-}" ]]; then echo "Missing required value: $1" >&2; exit 2; fi; } dep_check() { for cmd in curl jq python3; do command -v "$cmd" >/dev/null 2>&1 || { echo "Missing dependency: $cmd" >&2; exit 2; } done } dep_check # URL-encode via Python urlencode() { python3 - "$1" <<'PY' 2>/dev/null import sys, urllib.parse print(urllib.parse.quote(sys.argv[1], safe='')) PY } # Parse host/port from redirect for the tiny local server parse_host_port() { python3 - "$1" <<'PY' 2>/dev/null import sys, urllib.parse u = urllib.parse.urlparse(sys.argv[1]) host = u.hostname or "127.0.0.1" port = u.port or 8888 print(f"{host} {port}") PY } # PKCE helpers b64url() { python3 - <<'PY' 2>/dev/null import sys, base64 print(base64.urlsafe_b64encode(sys.stdin.read().encode()).decode().rstrip("=")) PY } sha256_hex() { python3 - <<'PY' 2>/dev/null import sys, hashlib print(hashlib.sha256(sys.stdin.read().encode()).digest().hex()) PY } # -------- prepare auth request -------- need CLIENT_ID if [[ "$USE_PKCE" != "true" ]]; then need CLIENT_SECRET fi if command -v openssl >/dev/null 2>&1; then STATE="$(openssl rand -hex 16)" else STATE="state$(date +%s)" fi if [[ "$USE_PKCE" == "true" ]]; then # RFC 7636: code_verifier 43-128 chars if command -v openssl >/dev/null 2>&1; then CODE_VERIFIER="$(openssl rand -hex 32)" else CODE_VERIFIER="cv$(date +%s)$(od -An -N8 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')" fi # code_challenge = BASE64URL-ENCODE(SHA256(code_verifier)) CODE_CHALLENGE="$(printf '%s' "$CODE_VERIFIER" | python3 - <<'PY' import sys, hashlib, base64 cv = sys.stdin.read().encode() print(base64.urlsafe_b64encode(hashlib.sha256(cv).digest()).decode().rstrip("=")) PY )" fi ENC_REDIRECT="$(urlencode "$REDIRECT_URI")" ENC_SCOPES="$(printf %s "$SCOPES" | sed 's/ /%20/g')" AUTH_URL="https://accounts.spotify.com/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${ENC_REDIRECT}&scope=${ENC_SCOPES}&state=${STATE}&show_dialog=true" if [[ "$USE_PKCE" == "true" ]]; then AUTH_URL="${AUTH_URL}&code_challenge_method=S256&code_challenge=$(urlencode "$CODE_CHALLENGE")" fi # -------- open browser -------- echo "Opening browser for Spotify consent…" if command -v xdg-open >/dev/null; then xdg-open "$AUTH_URL" >/dev/null 2>&1 || true elif command -v open >/dev/null; then open "$AUTH_URL" >/dev/null 2>&1 || true else echo "$AUTH_URL" fi # -------- tiny local callback server (single request) -------- read -r HOST PORT < <(parse_host_port "$REDIRECT_URI") echo "Listening on ${HOST}:${PORT} for the callback (Ctrl+C to abort)…" callback_json="$( python3 - "$HOST" "$PORT" <<'PY' 2>/dev/null || true import http.server, socketserver, sys, json from urllib.parse import urlparse, parse_qs HOST, PORT = sys.argv[1], int(sys.argv[2]) capt = {"code": "", "state": ""} class H(http.server.BaseHTTPRequestHandler): def do_GET(self): qs = parse_qs(urlparse(self.path).query) capt["code"] = (qs.get("code") or [""])[0] capt["state"] = (qs.get("state") or [""])[0] self.send_response(200) self.send_header("Content-Type","text/html") self.end_headers() self.wfile.write(b"<html><body><h2>OK</h2>You can close this tab.</body></html>") def log_message(self, *args): pass with socketserver.TCPServer((HOST, PORT), H) as httpd: httpd.timeout = 180 httpd.handle_request() print(json.dumps(capt)) PY )" CODE="$(printf '%s' "$callback_json" | jq -r '.code // empty')" GOT_STATE="$(printf '%s' "$callback_json" | jq -r '.state // empty')" if [[ -z "$CODE" ]]; then echo "No authorization code received." >&2 exit 3 fi if [[ "$GOT_STATE" != "$STATE" ]]; then echo "State mismatch. Aborting." >&2 exit 3 fi # -------- exchange code for tokens -------- TOKEN_URL="https://accounts.spotify.com/api/token" if [[ "$USE_PKCE" == "true" ]]; then TOKENS_JSON="$( curl -sS -X POST "$TOKEN_URL" \ -d grant_type=authorization_code \ --data-urlencode "code=${CODE}" \ --data-urlencode "redirect_uri=${REDIRECT_URI}" \ --data-urlencode "client_id=${CLIENT_ID}" \ --data-urlencode "code_verifier=${CODE_VERIFIER}" )" else TOKENS_JSON="$( curl -sS -X POST "$TOKEN_URL" \ -u "${CLIENT_ID}:${CLIENT_SECRET}" \ -d grant_type=authorization_code \ --data-urlencode "code=${CODE}" \ --data-urlencode "redirect_uri=${REDIRECT_URI}" )" fi REFRESH="$(printf '%s' "$TOKENS_JSON" | jq -r '.refresh_token // empty')" ACCESS="$(printf '%s' "$TOKENS_JSON" | jq -r '.access_token // empty')" ERR_DESC="$(printf '%s' "$TOKENS_JSON" | jq -r '.error_description // empty')" if [[ -z "$REFRESH" ]]; then echo "Did not receive refresh_token. Full response follows:" >&2 echo "$TOKENS_JSON" >&2 [[ -n "$ERR_DESC" ]] && echo "Hint: $ERR_DESC" >&2 exit 4 fi # -------- success output -------- cat <<EOF Success! Save these to the environment that runs Homebridge: export SPOTIFY_CLIENT_ID="$CLIENT_ID" EOF if [[ "$USE_PKCE" != "true" ]]; then echo "export SPOTIFY_CLIENT_SECRET=\"$CLIENT_SECRET\"" fi cat <<EOF export SPOTIFY_REFRESH_TOKEN="$REFRESH" Keep them secret. You can now use this refresh token in your playback script. EOF
The script will output the following:
Opening browser for Spotify consent… Listening on 127.0.0.1:8888 for the callback (Ctrl+C to abort)… Success! Save these to the environment that runs Homebridge: export SPOTIFY_CLIENT_ID="<YOUR CLIENT ID>" export SPOTIFY_CLIENT_SECRET="<YOUR SECRET>" export SPOTIFY_REFRESH_TOKEN="XXXXX_TOKEN_XXXXX" Keep them secret. You can now use this refresh token in your playback script.
Record the SPOTIFY_REFRESH_TOKEN.
Create your Spotify script. Update the SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET and SPOTIFY_REFRESH_TOKEN.
You can also add the device name, URI and starting volume:
SPOTIFY_DEVICE_NAME="Kitchen" SPOTIFY_URI='spotify:user:spotify:playlist:37i9dQZF1DXebxttQCq0zA' START_VOLUME=10
spotify-sonos-cmd4.sh
#!/usr/bin/env bash # spotify-sonos-cmd4.sh # Works with Cmd4 in either call style: # 1) "<AccessoryName>" On get|set [0|1] # 2) Get|Set "<AccessoryName>" "On" [0|1] set -euo pipefail SPOTIFY_CLIENT_ID=<SONOS_CLIENT_ID> SPOTIFY_CLIENT_SECRET=<SONOS_SECRET> SPOTIFY_REFRESH_TOKEN="<SONOS_TOKEN>" # ====== REQUIRED ENV ====== # Create a Spotify app at https://developer.spotify.com, then set: # SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN : "${SPOTIFY_CLIENT_ID:?Set SPOTIFY_CLIENT_ID}" : "${SPOTIFY_CLIENT_SECRET:?Set SPOTIFY_CLIENT_SECRET}" : "${SPOTIFY_REFRESH_TOKEN:?Set SPOTIFY_REFRESH_TOKEN}" # Sonos shows up as a Spotify Connect device. Use the *device name* as seen in the Spotify app. : "${SPOTIFY_DEVICE_NAME:?Set SPOTIFY_DEVICE_NAME (e.g., 'Kitchen')}" # What to play when turning ON (can be track/album/playlist/artist/show) # Examples: # spotify:track:3n3Ppam7vgaVa1iaRUc9Lp # spotify:album:1ATL5GLyefJaxhQzSPVrLX # spotify:playlist:37i9dQZF1DXcBWIGoYBM5M : "${SPOTIFY_URI:?Set SPOTIFY_URI}" # Optional: start position in ms START_POSITION_MS="${START_POSITION_MS:-0}" # Optional: volume (0-100) set after transfer; leave empty to skip START_VOLUME="${START_VOLUME:-}" # ====== ARGUMENT PARSING ====== A1="${1:-}"; A2="${2:-}"; A3="${3:-}"; A4="${4:-}" lower(){ printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]'; } if [[ "$(lower "$A1")" == "get" || "$(lower "$A1")" == "set" ]]; then ACTION="$(lower "$A1")"; ACCESSORY="$A2"; CHAR="$(lower "$A3")"; VALUE="${A4:-}" else ACCESSORY="$A1"; CHAR="$(lower "$A2")"; ACTION="$(lower "$A3")"; VALUE="${A4:-}" fi [[ "$CHAR" == "on" || "$CHAR" == "On" ]] || { echo "Unsupported characteristic: $CHAR" >&2; exit 1; } # ====== SPOTIFY HELPERS ====== API="https://api.spotify.com/v1" TOKEN_ENDPOINT="https://accounts.spotify.com/api/token" get_access_token() { curl -sS --fail -u "${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}" \ -d grant_type=refresh_token \ --data-urlencode "refresh_token=${SPOTIFY_REFRESH_TOKEN}" \ "$TOKEN_ENDPOINT" | jq -r '.access_token' } api() { local method="$1"; shift local path="$1"; shift local body="${1:-}" if [[ -n "$body" ]]; then curl -sS --fail -X "$method" "$API$path" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ --data "$body" else curl -sS --fail -X "$method" "$API$path" \ -H "Authorization: Bearer $ACCESS_TOKEN" fi } get_device_id_by_name() { api GET "/me/player/devices" | jq -r --arg NAME "$SPOTIFY_DEVICE_NAME" ' .devices[]? | select(.name==$NAME) | .id' | head -n1 } transfer_and_play() { local dev_id="$1" # Transfer playback to the Sonos device (and auto-play) api PUT "/me/player" "{\"device_ids\":[\"$dev_id\"],\"play\":true}" >/dev/null # Start the requested context/track(s) if [[ "$SPOTIFY_URI" == spotify:track:* ]]; then api PUT "/me/player/play?device_id=$dev_id" \ "{\"uris\":[\"$SPOTIFY_URI\"],\"position_ms\":$START_POSITION_MS}" >/dev/null else api PUT "/me/player/play?device_id=$dev_id" \ "{\"context_uri\":\"$SPOTIFY_URI\",\"offset\":{\"position\":0},\"position_ms\":$START_POSITION_MS}" >/dev/null fi # Optional volume if [[ -n "${START_VOLUME}" ]]; then api PUT "/me/player/volume?device_id=$dev_id&volume_percent=$START_VOLUME" >/dev/null || true fi } pause_playback() { # Pause regardless of device api PUT "/me/player/pause" "" >/dev/null || true } is_playing_here() { local cur cur="$(api GET "/me/player" || true)" [[ -n "$cur" ]] || return 1 local dev_name playing dev_name="$(jq -r '.device.name // empty' <<<"$cur")" playing="$(jq -r '.is_playing // false' <<<"$cur")" [[ "$dev_name" == "$SPOTIFY_DEVICE_NAME" && "$playing" == "true" ]] } # ====== RUN ====== ACCESS_TOKEN="$(get_access_token)" case "$ACTION" in get) if is_playing_here; then echo 1; else echo 0; fi ;; set) if [[ "${VALUE:-0}" == "1" ]]; then # Ensure device appears: if Spotify hasn’t seen the Sonos recently, ask user to start Spotify once on that speaker via the app DEV_ID="$(get_device_id_by_name || true)" if [[ -z "${DEV_ID:-}" ]]; then echo "0" echo "Spotify device '$SPOTIFY_DEVICE_NAME' not found. Make sure it is online and visible in Spotify Connect, then try again." >&2 exit 3 fi transfer_and_play "$DEV_ID" else pause_playback fi ;; *) echo "Unknown action: $ACTION" >&2; exit 1;; esac
# play ./spotify-sonos-cmd4.sh "Kitchen_Spa" on set 1 # stop ./spotify-sonos-cmd4.sh "Kitchen_Spa" on set 0 # get status ./spotify-sonos-cmd4.sh "Kitchen_Spa" on get