Skip to content

Instantly share code, notes, and snippets.

@timvw
Last active March 3, 2026 09:25
Show Gist options
  • Select an option

  • Save timvw/84ef8768ff876ef6805012b3eb4015b0 to your computer and use it in GitHub Desktop.

Select an option

Save timvw/84ef8768ff876ef6805012b3eb4015b0 to your computer and use it in GitHub Desktop.
On-speaker Spotify boot primer for Bose SoundTouch — fetches token from soundcork, primes at boot, no jq needed

On-Speaker Spotify Boot Primer for Bose SoundTouch

Self-contained boot-time Spotify primer that runs directly on the speaker. No Spotify credentials on the device — it fetches a fresh token from a soundcork server at boot.

No jq, no rootfs modification — just files on persistent storage.

How It Works

Bose SoundTouch speakers run embedded Linux with a persistent writable volume at /mnt/nv. The init script shelby_local (S97) has a built-in hook:

[ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local

This runs before SoundTouch itself (S99), so we background a primer script that waits for the Spotify Connect ZeroConf endpoint (port 8200) to come up, fetches a fresh Spotify token from soundcork, and primes the speaker — all within ~30 seconds of boot.

File Layout

/mnt/nv/
  rc.local                                      boot hook (S97 checks this)
  .profile                                      PATH setup for interactive SSH
  bin/
    spotify-boot-primer                         main script
  BoseApp-Persistence/1/
    spotify-primer.conf                         soundcork credentials (mode 600)
    Sources.xml, Presets.xml, ...               existing speaker data

Scripts live in /mnt/nv/bin/ (added to PATH via .profile), config lives alongside the speaker's own persistence files in /mnt/nv/BoseApp-Persistence/1/.

Speaker Environment

Tested on SoundTouch 20. Other SoundTouch models likely similar.

Item Detail
OS Linux 3.14.43+ ARM (hostname spotty)
Root FS Read-only ubifs (can be remounted rw)
Persistent storage /mnt/nv — writable ubifs, ~24M free
curl 7.50.3 with OpenSSL (HTTPS works)
bash/grep/sed/awk Available via busybox
jq Not available (not needed)
Init SysV, runlevel 5
Production mode Yes — cron is disabled

Prerequisites

  1. SSH access to the speaker:

    ssh -o HostKeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa root@SPEAKER_IP
    
  2. A running soundcork server with:

    • A linked Spotify account (via the management API OAuth flow)
    • The GET /mgmt/spotify/token endpoint (returns {accessToken, username})
    • Management API credentials (HTTP Basic Auth)

Installation

SSH into the speaker and run:

# 1. Create bin directory
mkdir -p /mnt/nv/bin

# 2. Create the config file with your soundcork connection info
cat > /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf << 'EOF'
SOUNDCORK_URL=https://soundcork.example.com
SOUNDCORK_USER=admin
SOUNDCORK_PASS=secret
EOF
chmod 600 /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf

# 3. Copy spotify-boot-primer to the speaker (see file in this gist)
#    From your local machine:
#    cat spotify-boot-primer | ssh root@SPEAKER_IP "cat > /mnt/nv/bin/spotify-boot-primer"
chmod +x /mnt/nv/bin/spotify-boot-primer

# 4. Create the boot hook
cat > /mnt/nv/rc.local << 'EOF'
#!/bin/bash
/mnt/nv/bin/spotify-boot-primer &
EOF
chmod +x /mnt/nv/rc.local

# 5. Set up PATH for interactive SSH sessions (optional but convenient)
cat > /mnt/nv/.profile << 'EOF'
export PATH="/mnt/nv/bin:$PATH"
EOF

Testing

If activeUser is set but presets still fail, check spotify-primer logs for the activate-speaker step.

# Manual test (speaker must be running):
/mnt/nv/bin/spotify-boot-primer

# Check logs:
logread | grep spotify-primer

# Full test — reboot the speaker:
reboot
# Wait ~30s, then SSH back in and check:
logread | grep spotify-primer
curl -s "http://localhost:8200/zc?action=getInfo" | grep activeUser

Example Boot Log

spotify-primer[1735]: Config loaded (server=https://soundcork.example.com)
spotify-primer[1735]: Waiting for ZeroConf endpoint (max 120s)...
spotify-primer[1735]: ZeroConf endpoint is up (waited 21s)
spotify-primer[1735]: Speaker 'Bose-Woonkamer' has no active Spotify user — priming...
spotify-primer[1735]: Requesting Spotify token from soundcork...
spotify-primer[1735]: Got token for user {username} (BQDUAb0_2h...)
spotify-primer[1735]: addUser accepted (status 101) — verifying...
spotify-primer[1735]: Speaker primed successfully (activeUser={username})

Architecture

Speaker boot
  |
  v
shelby_local (S97) -- [ -x /mnt/nv/rc.local ] && /mnt/nv/rc.local
  |
  v                                 SoundTouch (S99)
rc.local                              |
  |                                    v
  +-- spotify-boot-primer &       port 8200 up
         |                             ^
         +-- wait for port 8200 ------+
         |
         +-- GET soundcork/mgmt/spotify/token
         |     (HTTP Basic Auth)
         |
         +-- POST localhost:8200/zc
         |     action=addUser
         |
         v
      Speaker primed for Spotify

Update Notes

2026-03: Improve preset reliability after boot

  • Added a post-prime activation call to soundcork:
    • POST /mgmt/spotify/activate-speaker?device_name=Bose
  • Reason: addUser (ZeroConf) can set activeUser but still leave the speaker in an idle/unselected Spotify state, causing SoundTouch presets to fail.
  • New behavior:
    1. Prime with ZeroConf (addUser)
    2. Verify activeUser
    3. Trigger activation via soundcork for more reliable preset playback
  • Activation failure is non-fatal: script logs a warning and exits 0 (Spotify Connect priming still succeeded).

Notes

  • The script is idempotent — if the speaker is already primed, it exits immediately
  • No Spotify credentials stored on the speaker — only the soundcork URL and management credentials
  • Soundcork handles all Spotify OAuth token refresh logic
  • The speaker needs network access to reach your soundcork server
  • /mnt/nv holds all user presets/config, so firmware updates should preserve it — but back up your files just in case

Related

#!/bin/bash
# /mnt/nv/rc.local — runs at boot via shelby_local (S97)
# Launches Spotify boot primer in background since SoundTouch starts at S99
/mnt/nv/bin/spotify-boot-primer &
#!/bin/bash
#
# spotify-boot-primer — Self-contained Spotify primer for Bose SoundTouch speakers
#
# Runs at boot (via /mnt/nv/rc.local), waits for the ZeroConf endpoint to
# come up, fetches a fresh Spotify token from a soundcork server, and primes
# the speaker. No Spotify credentials stored on the device.
#
# Only needs: curl, grep, sed (all available on the speaker via busybox).
#
# Install:
# 1. mkdir -p /mnt/nv/bin
# 2. Copy this script to /mnt/nv/bin/spotify-boot-primer
# 3. Create /mnt/nv/BoseApp-Persistence/1/spotify-primer.conf
# 4. Create /mnt/nv/rc.local that backgrounds this script
# 5. chmod +x /mnt/nv/rc.local /mnt/nv/bin/spotify-boot-primer
#
# Config file format (/mnt/nv/BoseApp-Persistence/1/spotify-primer.conf):
# SOUNDCORK_URL=https://soundcork.example.com
# SOUNDCORK_USER=admin
# SOUNDCORK_PASS=secret
#
# Related:
# https://github.com/timvw/soundcork
# https://github.com/gmuth/soundtouch-device-survival
# https://gist.github.com/timvw/84ef8768ff876ef6805012b3eb4015b0
#
set -uo pipefail
CONF="/mnt/nv/BoseApp-Persistence/1/spotify-primer.conf"
LOG_TAG="spotify-primer[$$]"
ZC_URL="http://localhost:8200/zc"
MAX_WAIT=120 # max seconds to wait for port 8200
RETRY_DELAY=3 # seconds between retries
# --- Logging ---
log() {
logger -s -t "$LOG_TAG" -p "$1" "$2"
}
# --- JSON parsing without jq ---
# Extract a string value: echo '{"key":"val"}' | json_str key
json_str() {
grep -o "\"$1\" *: *\"[^\"]*\"" | sed "s/\"$1\" *: *\"//;s/\"$//"
}
# Extract a numeric value: echo '{"key":123}' | json_num key
json_num() {
grep -o "\"$1\" *: *[0-9]*" | sed "s/\"$1\" *: *//"
}
# --- Load config ---
if [ ! -f "$CONF" ]; then
log err "Config not found: $CONF"
exit 1
fi
. "$CONF"
for var in SOUNDCORK_URL SOUNDCORK_USER SOUNDCORK_PASS; do
if [ -z "${!var:-}" ]; then
log err "Missing $var in $CONF"
exit 1
fi
done
log info "Config loaded (server=${SOUNDCORK_URL})"
# --- Wait for ZeroConf endpoint (port 8200) ---
log info "Waiting for ZeroConf endpoint (max ${MAX_WAIT}s)..."
waited=0
while true; do
if curl -sf --max-time 2 "${ZC_URL}?action=getInfo" >/dev/null 2>&1; then
break
fi
waited=$((waited + RETRY_DELAY))
if [ $waited -ge $MAX_WAIT ]; then
log err "ZeroConf endpoint not available after ${MAX_WAIT}s — giving up"
exit 1
fi
sleep $RETRY_DELAY
done
log info "ZeroConf endpoint is up (waited ${waited}s)"
# --- Check if already primed ---
info=$(curl -sf --max-time 5 "${ZC_URL}?action=getInfo" 2>/dev/null)
active_user=$(echo "$info" | json_str activeUser)
device_name=$(echo "$info" | json_str remoteName)
if [ -n "$active_user" ]; then
log info "Already primed (device=$device_name, activeUser=$active_user) — nothing to do"
exit 0
fi
log info "Speaker '$device_name' has no active Spotify user — priming..."
# --- Get token from soundcork server ---
log info "Requesting Spotify token from soundcork..."
token_response=$(curl -sf --max-time 15 \
-u "${SOUNDCORK_USER}:${SOUNDCORK_PASS}" \
"${SOUNDCORK_URL}/mgmt/spotify/token" \
2>&1)
if [ $? -ne 0 ] || [ -z "$token_response" ]; then
log err "Failed to get token from soundcork (is the server reachable?)"
exit 1
fi
access_token=$(echo "$token_response" | json_str accessToken)
user=$(echo "$token_response" | json_str username)
if [ -z "$access_token" ] || [ -z "$user" ]; then
error_msg=$(echo "$token_response" | json_str detail)
log err "Soundcork returned error: ${error_msg:-no token/username in response}"
exit 1
fi
log info "Got token for user $user (${access_token:0:10}...)"
# --- Prime the speaker ---
result=$(curl -sf --max-time 10 -X POST "$ZC_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=addUser&userName=${user}&blob=${access_token}&clientKey=&tokenType=accesstoken" \
2>&1)
status=$(echo "$result" | json_num status)
status_str=$(echo "$result" | json_str statusString)
if [ "$status" != "101" ]; then
log err "addUser failed: status=$status ($status_str)"
exit 1
fi
# --- Verify (retry — speaker needs a few seconds after cold boot) ---
log info "addUser accepted (status 101) — verifying..."
primed=false
for i in 1 2 3 4 5; do
sleep $((i * 2))
active_user=$(curl -sf --max-time 5 "${ZC_URL}?action=getInfo" | json_str activeUser)
if [ -n "$active_user" ]; then
log info "Speaker primed successfully (activeUser=$active_user)"
primed=true
break
fi
done
if [ "$primed" != "true" ]; then
log warning "Speaker accepted addUser but activeUser still empty after 30s"
exit 1
fi
# --- Activate speaker via Spotify Web API ---
# ZeroConf priming registers the speaker with Spotify but leaves it idle.
# The Spotify desktop app activates the streaming session by calling
# PUT /v1/me/player — we replicate that via soundcork so that SoundTouch
# presets can initiate Spotify playback without manual intervention.
log info "Activating speaker streaming session via soundcork..."
sleep 5 # give the speaker time to register with Spotify's servers
activate_response=$(curl -sf --max-time 60 \
-X POST \
-u "${SOUNDCORK_USER}:${SOUNDCORK_PASS}" \
"${SOUNDCORK_URL}/mgmt/spotify/activate-speaker?device_name=Bose" \
2>&1)
if [ $? -ne 0 ] || [ -z "$activate_response" ]; then
log warning "Speaker activation failed (presets may not work until Spotify app connects)"
# Non-fatal: ZeroConf priming succeeded, Spotify Connect works.
# Only SoundTouch preset playback is affected.
exit 0
fi
log info "Speaker activated: ${activate_response}"
exit 0
#!/usr/bin/env bash
#
# spotify-prime-speaker — Prime a Bose SoundTouch speaker for Spotify playback
#
# Activates Spotify on a SoundTouch speaker by sending an access token
# via the Spotify Connect ZeroConf endpoint (port 8200). This is the
# same mechanism the Spotify desktop app uses internally.
#
# Works standalone — no soundcork, ueberboese, or other server required.
#
# Requirements: curl, jq
#
# Usage:
# ./spotify-prime-speaker SPEAKER_IP ACCESS_TOKEN
#
# Example:
# ./spotify-prime-speaker 192.168.1.143 BQDj...your_token...
#
# How to get an access token:
# - Spotify Developer Console: https://developer.spotify.com
# (create an app, use the "Get Token" button)
# - Via soundcork management API: POST /mgmt/spotify/auth/init
# - Via ueberboese management API: POST /mgmt/spotify/init
# - Any Spotify OAuth Authorization Code flow with user-read-email scope
#
# Notes:
# - Access tokens expire after 1 hour (3600 seconds)
# - The speaker must be on the same network and reachable on port 8200
# - After priming, Spotify presets on the speaker should work immediately
# - Re-run after each speaker reboot (or use a server like soundcork
# to automate this)
#
# How it works:
# The Bose SoundTouch speaker exposes a Spotify Connect ZeroConf API
# on port 8200. By sending an addUser request with a valid Spotify
# access token, the speaker activates its built-in Spotify Connect
# client. No encryption is needed — the token is sent as plain text,
# exactly like the Spotify desktop app does it.
#
# Related:
# - https://github.com/deborahgu/soundcork (replacement Bose cloud server)
# - https://github.com/deborahgu/soundcork/pull/185 (Spotify support PR)
# - https://github.com/deborahgu/soundcork/issues/107 (Spotify presets issue)
set -euo pipefail
# --- Argument parsing ---
if [ $# -lt 2 ]; then
echo "Usage: $0 SPEAKER_IP ACCESS_TOKEN"
echo ""
echo "Prime a Bose SoundTouch speaker for Spotify playback."
echo ""
echo "Arguments:"
echo " SPEAKER_IP IP address of the SoundTouch speaker"
echo " ACCESS_TOKEN Spotify access token (starts with BQ...)"
echo ""
echo "Get a token at https://developer.spotify.com or via a server's OAuth flow."
exit 1
fi
SPEAKER_IP="$1"
TOKEN="$2"
ZC_URL="http://${SPEAKER_IP}:8200/zc"
# --- Dependency check ---
for cmd in curl jq; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: $cmd is required but not installed." >&2
exit 1
fi
done
# --- Step 1: Discover Spotify username from token ---
echo "Discovering Spotify user from token..."
ME_RESPONSE=$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
https://api.spotify.com/v1/me 2>&1) || {
echo "Error: Failed to call Spotify /me API. Is the token valid?" >&2
echo " (tokens expire after 1 hour)" >&2
exit 1
}
USER=$(echo "$ME_RESPONSE" | jq -r '.id // empty')
if [ -z "$USER" ]; then
echo "Error: Could not extract user ID from Spotify response." >&2
echo "$ME_RESPONSE" >&2
exit 1
fi
echo " Spotify user: $USER"
# --- Step 2: Check current speaker status ---
echo "Checking speaker at ${SPEAKER_IP}:8200..."
INFO=$(curl -sf "${ZC_URL}?action=getInfo" 2>&1) || {
echo "Error: Could not reach speaker at ${SPEAKER_IP}:8200." >&2
echo " Is the speaker on and on the same network?" >&2
exit 1
}
ACTIVE=$(echo "$INFO" | jq -r '.activeUser // empty')
DEVICE_NAME=$(echo "$INFO" | jq -r '.remoteName // empty')
if [ -n "$DEVICE_NAME" ]; then
echo " Speaker: $DEVICE_NAME"
fi
if [ -n "$ACTIVE" ]; then
echo " Already primed (activeUser=$ACTIVE)"
echo "Done — speaker is ready for Spotify playback."
exit 0
fi
echo " No active Spotify user — priming now..."
# --- Step 3: Send addUser ---
RESULT=$(curl -sf -X POST "${ZC_URL}" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "action=addUser&userName=${USER}&blob=${TOKEN}&clientKey=&tokenType=accesstoken" \
2>&1) || {
echo "Error: addUser request failed." >&2
exit 1
}
STATUS=$(echo "$RESULT" | jq -r '.status // -1')
STATUS_STR=$(echo "$RESULT" | jq -r '.statusString // empty')
if [ "$STATUS" != "101" ]; then
echo "Error: Speaker returned status $STATUS ($STATUS_STR)" >&2
echo "$RESULT" | jq . >&2
exit 1
fi
echo " Speaker accepted the token (status 101)."
# --- Step 4: Verify ---
echo " Verifying (waiting 2 seconds)..."
sleep 2
ACTIVE=$(curl -sf "${ZC_URL}?action=getInfo" | jq -r '.activeUser // empty')
if [ -n "$ACTIVE" ]; then
echo "Done — speaker primed for Spotify (activeUser=$ACTIVE)"
else
echo "Warning: Speaker returned 101 but activeUser is still empty."
echo " The speaker may need more time. Try pressing a Spotify preset."
exit 1
fi
# /mnt/nv/spotify-primer.conf — soundcork connection for boot primer
# The speaker fetches a fresh Spotify token from soundcork at boot.
# No Spotify credentials needed on the device.
SOUNDCORK_URL=https://soundcork.example.com
SOUNDCORK_USER=admin
SOUNDCORK_PASS=secret
@timvw
Copy link
Author

timvw commented Feb 22, 2026

Can't remember but after boot or power down, simply starting by pressing a spotify preset, did not work.. that's why I prefer the on-device script.. (+ no complexity of having a backend that needs to connect to the speaker(s)).

Anyway, we're all learning here.. Let's keep sharing experiences :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment