Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.

Table of Contents

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.


Image Added

Get Refresh Token

Run the following script using the following parameters:

Code Block
themeEmacs
SPOTIFY_CLIENT_ID=... SPOTIFY_CLIENT_SECRET=... ./get-spotify-refresh-token.sh


Code Block
languagebash
titleget-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


Image AddedImage Added

The script will output the following:

Code Block
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:

Code Block
SPOTIFY_DEVICE_NAME="Kitchen"
SPOTIFY_URI='spotify:user:spotify:playlist:37i9dQZF1DXebxttQCq0zA'
START_VOLUME=10



Code Block
titlespotify-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



Code Block
themeEmacs
# 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