mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
feat(spotify): wire preset storage end-to-end via server-centric priming (#302)
storePreset on the speaker was failing with "AddPreset - failed due to invalid SourceID" because the watchdog priming path only pushed ZeroConf credentials and never registered a SPOTIFY ConfiguredSource in marge. PrimeDeviceWithSpotify now: - resolves the device's paired account via live :8090/info (margeAccountUUID), falling back to ServiceDeviceInfo.AccountID — same order as setup.populateDeviceInfo; - writes a SPOTIFY ConfiguredSource under that account (providerID=15, BoseSecret as credential), mirroring bridgeSpotifyToMarge; - POSTs `<updates><sourcesUpdated/></updates>` so the speaker re-fetches its on-device Sources.xml from marge. Also introduce zeroconf.ErrAddUserNoOp for the narrow firmware quirk (404 + empty body on ?action=addUser when activeUser already matches). Recognised only on that exact pattern; real 4xx/5xx still surface loudly with full response details. Same treatment applied to Amazon priming. Docs: - new docs/concepts/spotify-overview.md anchors the topic (mental model, streamingoauth.bose.com DNS gotcha, token lifecycle, clientId notes, troubleshooting table); - spotify-oauth.md drops the removed install-primer endpoint and the on-device boot-primer install sections, adds /mgmt/spotify/prime; - spotify-priming-strategy.md and MUSIC-SERVICES.md link to the overview. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e64481f008
commit
b0d7e8aae2
@@ -1,5 +1,10 @@
|
||||
# Spotify OAuth Integration
|
||||
|
||||
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
|
||||
> mental model (Spotify Connect vs OAuth-intercept, DNS rewrite gotcha,
|
||||
> end-to-end token lifecycle). This document zooms in on the OAuth flows and
|
||||
> management endpoints.
|
||||
|
||||
The SoundTouch service supports Spotify OAuth integration to broker access tokens for SoundTouch speakers. This is particularly useful for maintaining Spotify Connect functionality after the Bose cloud shutdown (scheduled for May 2026).
|
||||
|
||||
## OAuth Flows
|
||||
@@ -91,43 +96,23 @@ sequenceDiagram
|
||||
Note over Speaker: Speaker now has Spotify access
|
||||
```
|
||||
|
||||
## Boot Primer Script
|
||||
## Priming Speakers
|
||||
|
||||
A boot primer script that uses these endpoints to feed Spotify tokens to speakers via ZeroConf is available in the `scripts/spotify/` directory: [spotify-boot-primer.sh](../../scripts/spotify/spotify-boot-primer.sh).
|
||||
|
||||
This script can be installed on the speaker itself (which runs embedded Linux) to automatically prime Spotify Connect at boot time. See [README.md](../../scripts/spotify/README.md) and [INSTALL.md](../../scripts/spotify/INSTALL.md) for instructions.
|
||||
|
||||
### Automated Installation via Service
|
||||
|
||||
The SoundTouch service provides a dedicated management endpoint to automatically handle the installation of the Spotify boot primer on the speaker:
|
||||
`POST /mgmt/devices/{deviceId}/spotify/install-primer`
|
||||
|
||||
### Automated Installation Steps
|
||||
When you run the Spotify primer installation, the service performs the following:
|
||||
1. **Directories**: Creates `/mnt/nv/bin` and `/mnt/nv/BoseApp-Persistence/1` on the speaker.
|
||||
2. **Binary**: Uploads the `spotify-boot-primer` script to the speaker.
|
||||
3. **Configuration**: Automatically generates and uploads `spotify-primer.conf` containing the service's URL and management credentials.
|
||||
4. **Boot Hook**: Injects a call to the primer in the speaker's `/mnt/nv/rc.local` using idempotent markers.
|
||||
5. **Environment**: Updates `/mnt/nv/.profile` to include `/mnt/nv/bin` in the `PATH` for easier manual troubleshooting via SSH.
|
||||
|
||||
- **Idempotent Patching**: The service uses explicit markers to inject the hook, ensuring it doesn't corrupt existing content.
|
||||
- **Coexistence**: The service-injected hook is designed to coexist with a manually installed `rc.local` (e.g., from the community gist). It only adds a call to `/mnt/nv/bin/spotify-boot-primer` if it's not already managed by a service-controlled block.
|
||||
- **Markers**: Look for the following markers in your speaker's `/mnt/nv/rc.local`:
|
||||
- `# --- Aftertouch Spotify hook START ---`
|
||||
- `# --- Aftertouch Spotify hook END ---`
|
||||
- **Cleanup**: Reverting a migration via the service will cleanly remove these marker-delimited blocks.
|
||||
> **Note:** The on-device boot-primer flow (installing `spotify-boot-primer.sh` onto the speaker's `/mnt/nv` and hooking it from `rc.local`) is **deprecated**. AfterTouch now uses a server-centric model: the service registers a `SPOTIFY` source in marge for the device's paired account and pushes credentials via ZeroConf from the server side, triggered on `power_on` and a manual "Prime" action. See [spotify-priming-strategy.md](spotify-priming-strategy.md) for the current model and rationale.
|
||||
>
|
||||
> The artifacts under `scripts/spotify/` are kept as historical reference for users who still rely on the on-device approach. There is no longer a `/mgmt/devices/{deviceId}/spotify/install-primer` endpoint.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|---------------------------------------------------|-------|-----------------------------------------------------------------------|
|
||||
| POST | `/mgmt/devices/{deviceId}/spotify/install-primer` | Basic | Install Spotify boot primer on speaker (deviceId or IP) |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/init` | Basic | Start OAuth flow, returns authorization URL |
|
||||
| GET | `/mgmt/spotify/callback` | None | Browser OAuth callback (redirect from Spotify, returns HTML) |
|
||||
| POST | `/mgmt/spotify/confirm` | Basic | Mobile app confirm (ueberboese deep link delivers code, returns JSON) |
|
||||
| GET | `/mgmt/spotify/accounts` | Basic | List linked Spotify accounts (tokens stripped) |
|
||||
| GET | `/mgmt/spotify/token` | Basic | Get fresh access token (auto-refreshes if expired) |
|
||||
| POST | `/mgmt/spotify/entity` | Basic | Resolve Spotify URI to name + image URL |
|
||||
| POST | `/mgmt/spotify/prime` | Basic | Manually trigger server-side priming of a discovered speaker |
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Spotify on SoundTouch — Overview
|
||||
|
||||
This is the entry point for understanding how Spotify works on a SoundTouch
|
||||
speaker behind AfterTouch. Read this first; the deeper docs assume you already
|
||||
have the mental model below.
|
||||
|
||||
> **Premium likely required.** As far as we know, Spotify Connect on
|
||||
> SoundTouch only works with a Spotify Premium account — this matches our
|
||||
> testing and matches what other SoundTouch-replacement projects report, but
|
||||
> we have not exhaustively verified every account tier or region. None of the
|
||||
> workarounds in this document change Spotify's account-tier requirements.
|
||||
|
||||
## Two completely separate Spotify paths
|
||||
|
||||
These are routinely confused. They share a speaker and a Spotify account, but
|
||||
they ride on different infrastructure and fail for different reasons.
|
||||
|
||||
### 1. Spotify Connect (speaker-native, independent of AfterTouch)
|
||||
|
||||
- The speaker advertises itself on the LAN as a Spotify Connect endpoint
|
||||
(mDNS service `_spotify-connect._tcp`).
|
||||
- You open the Spotify app on your phone or desktop, tap the Connect device
|
||||
picker, and select the SoundTouch.
|
||||
- Audio streams directly from Spotify's CDN to the speaker. Token handling,
|
||||
session setup, and playback all happen between Spotify and the speaker.
|
||||
- **AfterTouch is not involved.** It still works even if AfterTouch is
|
||||
offline.
|
||||
|
||||
This is the simplest path. If you only want to push playback from your phone,
|
||||
you do not need to link Spotify to AfterTouch at all — see [Manual kick-start
|
||||
alternative](#manual-kick-start-alternative) below.
|
||||
|
||||
### 2. OAuth-intercept path (managed by AfterTouch)
|
||||
|
||||
This is what enables features that originate **from the speaker**:
|
||||
|
||||
- Spotify presets on the speaker's buttons.
|
||||
- Spotify playback from the Bose app's source picker.
|
||||
- "Resume Spotify" after a power cycle without touching the Spotify app.
|
||||
|
||||
After Bose's cloud shutdown (May 2026), the speaker can no longer reach
|
||||
Bose's OAuth server for Spotify token refresh. AfterTouch intercepts those
|
||||
calls via DNS, brokers tokens with Spotify using your linked account, and
|
||||
hands them back to the speaker.
|
||||
|
||||
The rest of this document describes that path.
|
||||
|
||||
## Setup at a glance
|
||||
|
||||
Full step-by-step is in
|
||||
[docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md). Summary:
|
||||
|
||||
1. **Register a Spotify developer app** (one-time, by the AfterTouch operator).
|
||||
2. **Configure AfterTouch** with the Client ID, Client Secret, and Redirect
|
||||
URI in the Settings tab.
|
||||
3. **Authorize your Spotify account** via the Local Account tab — completes
|
||||
the OAuth flow and persists a long-lived refresh token to AfterTouch's
|
||||
datastore.
|
||||
4. **Prime each speaker** so its source list and ZeroConf state know about
|
||||
Spotify.
|
||||
|
||||
After step 4, presets and Bose-app-initiated Spotify playback work.
|
||||
|
||||
## The DNS rewrite — easy to miss, breaks everything
|
||||
|
||||
Bose firmware does **not** read a separate OAuth server hostname from
|
||||
configuration. It derives the OAuth host from the marge host by inserting
|
||||
`oauth` into the first label:
|
||||
|
||||
| Purpose | Hostname |
|
||||
|-----------------|---------------------------|
|
||||
| Marge / sources | `streaming.bose.com` |
|
||||
| OAuth refresh | `streamingoauth.bose.com` |
|
||||
|
||||
**Both hostnames must resolve to AfterTouch.** AfterTouch's DNS server hijacks
|
||||
both, but if you bypass that DNS server (e.g. by hard-coding only the marge
|
||||
hostname in `/etc/hosts`, or by routing only one through a custom resolver),
|
||||
token refresh will silently die while the speaker still pulls sources.
|
||||
Symptom: the speaker briefly streams Spotify after priming, then stops at the
|
||||
first token refresh ~1 hour later.
|
||||
|
||||
If you self-host AfterTouch at e.g. `aftertouch.local`, you would equivalently
|
||||
need `aftertouchoauth.local` for the OAuth interception path.
|
||||
|
||||
## End-to-end token lifecycle
|
||||
|
||||
What actually happens, from priming to steady-state playback:
|
||||
|
||||
1. **Operator links Spotify account.** OAuth flow stores
|
||||
`{user_id, refresh_token, bose_secret}` in `spotify/accounts.json`. The
|
||||
`bose_secret` is an opaque surrogate (e.g. `bs-deadbeef…`) that AfterTouch
|
||||
issues; the speaker only ever sees this surrogate, never the real Spotify
|
||||
refresh token.
|
||||
2. **Priming runs.** Either on speaker `power_on`, on discovery, or on a
|
||||
manual `POST /mgmt/spotify/prime`. AfterTouch:
|
||||
- Resolves the speaker's currently-paired account via live `:8090/info`
|
||||
(`margeAccountUUID`).
|
||||
- Writes a `SPOTIFY` `ConfiguredSource` into marge under that account with
|
||||
`secret = bose_secret`, `secretType = token_version_3`.
|
||||
- POSTs `<updates><sourcesUpdated/></updates>` to the speaker's
|
||||
`:8090/notification`, causing the speaker to re-fetch
|
||||
`/streaming/account/{account}/full` and pick up the new source.
|
||||
- Optionally pushes a fresh access token to the speaker's ZeroConf
|
||||
endpoint (`:8200/zc?action=addUser`). This is best-effort — see
|
||||
[ZeroConf clientId and benign 404s](#zeroconf-clientid-and-benign-404s).
|
||||
3. **Speaker pulls sources.** It now has a SPOTIFY entry with the surrogate
|
||||
as its credential. The speaker stores this; from its perspective the
|
||||
surrogate is the refresh token.
|
||||
4. **Speaker uses Spotify.** When it needs a fresh access token (every ~1 h
|
||||
on Spotify's clock), it POSTs to
|
||||
`streamingoauth.bose.com/oauth/device/{deviceID}/music/musicprovider/15/token/cs3`
|
||||
with the surrogate.
|
||||
5. **AfterTouch translates.** DNS hijack routes the request to AfterTouch,
|
||||
which looks up the surrogate, performs the real refresh against Spotify
|
||||
using the stored refresh token, and returns the resulting access token to
|
||||
the speaker.
|
||||
6. **Speaker uses the access token** for Spotify Web API metadata calls
|
||||
(artwork, track lookups, playback container resolution).
|
||||
|
||||
Forensic details of the request shapes are in
|
||||
[docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md).
|
||||
The cryptographic specifics of the ZeroConf `addUser` blob are in
|
||||
[spotify-priming-strategy.md](spotify-priming-strategy.md).
|
||||
|
||||
## ZeroConf clientId and benign 404s
|
||||
|
||||
`GET http://<speaker>:8200/zc?action=getInfo` returns, among other fields:
|
||||
|
||||
```json
|
||||
"clientID": "79ebcb219e8e4e9a892e796607931810"
|
||||
"tokenType": "accesstoken"
|
||||
"activeUser": "<spotify-user-id-or-empty>"
|
||||
```
|
||||
|
||||
That `clientID` is **Bose's official Spotify Connect partner client_id**,
|
||||
baked into firmware. It is **not** the client_id of the developer app you
|
||||
registered for AfterTouch — those are two unrelated OAuth apps, by design.
|
||||
The Bose-baked one is what Spotify Connect uses when a Spotify mobile app
|
||||
discovers the speaker on the LAN. The AfterTouch-registered one is what
|
||||
brokers refresh tokens for the OAuth-intercept path. They never converge.
|
||||
|
||||
**Implication:** an access token AfterTouch obtained under its own client_id
|
||||
is not directly usable as a Spotify Connect session token. Pushing it via
|
||||
ZeroConf `addUser` is best-effort, and the speaker may respond with a `404`
|
||||
and an empty body when its `activeUser` already matches the username being
|
||||
pushed — that is the firmware's idiomatic "no transition required" signal,
|
||||
not a failure. AfterTouch recognises this case (`zeroconf.ErrAddUserNoOp`)
|
||||
and logs it as an expected no-op rather than an error.
|
||||
|
||||
A 404 **with a body**, or any other non-2xx, is treated as a real failure
|
||||
and logged loudly with the response headers and body so it can be
|
||||
diagnosed.
|
||||
|
||||
## Manual kick-start alternative
|
||||
|
||||
You can skip the OAuth setup entirely if you only want playback pushed from
|
||||
the Spotify app:
|
||||
|
||||
1. Open the Spotify mobile/desktop app.
|
||||
2. Start any track.
|
||||
3. Open the Connect device picker, select the SoundTouch.
|
||||
|
||||
The speaker now holds an in-memory Spotify Connect session and can play
|
||||
until next reboot. Presets and Bose-app-initiated Spotify playback will
|
||||
still not work — those require the OAuth-intercept path — but Spotify-app-
|
||||
initiated playback does.
|
||||
|
||||
## Troubleshooting quick reference
|
||||
|
||||
| Symptom | Most likely cause |
|
||||
|----------------------------------------------------|------------------------------------------------------------------------------------------------|
|
||||
| Preset stores then fails: "invalid SourceID" | No `SPOTIFY` source in marge for the speaker's paired account. Re-run priming. |
|
||||
| Preset stores fine; playback dies after ~1 hour | `streamingoauth.bose.com` not pointed at AfterTouch (DNS rewrite gap). |
|
||||
| Speaker has source but `Sources.xml` looks stale | `<sourcesUpdated/>` notification did not reach the speaker. Re-run priming or POST it by hand. |
|
||||
| ZeroConf `addUser` returns 404, empty body | Benign no-op; speaker already has `activeUser` set. Marge path is authoritative. |
|
||||
| Spotify Connect device picker doesn't show speaker | Unrelated to AfterTouch; check the speaker's mDNS visibility on the LAN. |
|
||||
|
||||
## Where to go next
|
||||
|
||||
- **Setup walkthrough:** [docs/guides/MUSIC-SERVICES.md](../guides/MUSIC-SERVICES.md)
|
||||
- **OAuth flow details (browser + mobile + endpoint table):** [spotify-oauth.md](spotify-oauth.md)
|
||||
- **Priming strategy, ZeroConf DH protocol, deployment topologies:** [spotify-priming-strategy.md](spotify-priming-strategy.md)
|
||||
- **Forensic request/response analysis from the Stockholm app:** [docs/reference/spotify-account-addition.md](../reference/spotify-account-addition.md)
|
||||
@@ -1,5 +1,9 @@
|
||||
# Spotify Priming Strategy
|
||||
|
||||
> **New here?** Start with [spotify-overview.md](spotify-overview.md) for the
|
||||
> mental model. This document goes deep on the priming protocol, ZeroConf DH
|
||||
> exchange, and deployment topologies.
|
||||
|
||||
This document outlines the strategy for ensuring Bose SoundTouch devices are correctly "primed" for Spotify Connect integration within the AfterTouch ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
This guide explains how to link your Spotify or Amazon Music account to AfterTouch so your speakers can stream music from those services.
|
||||
|
||||
> For Spotify, a higher-level mental model of how the integration works —
|
||||
> Spotify Connect vs. AfterTouch's OAuth-intercept path, the
|
||||
> `streamingoauth.bose.com` DNS gotcha, and the token lifecycle — is in
|
||||
> [docs/concepts/spotify-overview.md](../concepts/spotify-overview.md).
|
||||
> Read that if priming or playback isn't behaving as you'd expect.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -2,6 +2,10 @@ package amazon
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
|
||||
|
||||
// ErrAddUserNoOp re-exports zeroconf.ErrAddUserNoOp for callers that don't
|
||||
// want a direct dependency on the zeroconf package.
|
||||
var ErrAddUserNoOp = zeroconf.ErrAddUserNoOp
|
||||
|
||||
// PushAmazonCredentials pushes Amazon Music credentials to a speaker using the
|
||||
// ZeroConf DH key exchange protocol. Falls back to simplified token push if
|
||||
// the speaker does not support DH (older firmware).
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
)
|
||||
|
||||
// TestPrimeDeviceWithSpotify_RegistersMargeSource is a regression test for the
|
||||
// "AddPreset - failed due to invalid SourceID" failure observed when storing a
|
||||
// Spotify preset on a primed device. The watchdog priming path used to push
|
||||
// ZeroConf credentials without writing a SPOTIFY ConfiguredSource into the
|
||||
// marge datastore — so marge.UpdatePreset later had nothing to match
|
||||
// SourceID="SPOTIFY" against and rejected the storePreset request.
|
||||
//
|
||||
// This test verifies that PrimeDeviceWithSpotify now also calls marge.AddSource
|
||||
// for the device's account, producing a ConfiguredSource with
|
||||
// SourceProviderID="15" (constants.SpotifyProviderID).
|
||||
func TestPrimeDeviceWithSpotify_RegistersMargeSource(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
// Fake speaker that accepts the ZeroConf push via the simplified
|
||||
// (non-DH) fallback AND records whether /notification (sourcesUpdated)
|
||||
// was hit.
|
||||
var notified atomic.Bool
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/notification" {
|
||||
notified.Store(true)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?><status>/notification</status>`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerHostPort := strings.TrimPrefix(speakerTS.URL, "http://")
|
||||
|
||||
speakerHost, _, err := net.SplitHostPort(speakerHostPort)
|
||||
if err != nil {
|
||||
t.Fatalf("split speaker URL: %v", err)
|
||||
}
|
||||
|
||||
// Register the device under a real account so the IP→account lookup succeeds.
|
||||
const accountID = "acc-prime"
|
||||
const deviceID = "DEVPRIME"
|
||||
devInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
Name: "Test Speaker",
|
||||
IPAddress: speakerHost,
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, devInfo); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
// marge.AddSource walks the account/devices dir — make sure the per-device
|
||||
// subdir exists so the source actually gets persisted.
|
||||
if err := os.MkdirAll(filepath.Join(ds.AccountDevicesDir(accountID), deviceID), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll device dir: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed a linked Spotify account so PrimeDeviceWithSpotify has something
|
||||
// to push. The token is valid for an hour so GetFreshToken won't try to
|
||||
// refresh against a live endpoint. We point the token endpoint at a noop
|
||||
// URL just in case, so a stray refresh would fail loudly rather than fan
|
||||
// out to the internet.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
if err := os.MkdirAll(spotifyDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll spotify dir: %v", err)
|
||||
}
|
||||
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"email": "user@example.com",
|
||||
"access_token": "fresh-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600); err != nil {
|
||||
t.Fatalf("write accounts.json: %v", err)
|
||||
}
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
// Unused fallback token endpoint — defensive in case the test ever drifts
|
||||
// to an expired token.
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
if len(ss.GetAccounts()) != 1 {
|
||||
t.Fatalf("expected 1 spotify account after Load, got %d", len(ss.GetAccounts()))
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
// Sanity: no SPOTIFY source registered yet.
|
||||
sources, _ := ds.GetConfiguredSources(accountID, deviceID)
|
||||
if hasSpotifySource(sources) {
|
||||
t.Fatalf("precondition failed: SPOTIFY source already present before priming")
|
||||
}
|
||||
|
||||
// Pass host:port so the ZeroConf push hits our test server instead of the
|
||||
// hard-coded :8200 fallback. The IP→account lookup strips the port before
|
||||
// matching against devInfo.IPAddress.
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
sources, err = ds.GetConfiguredSources(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources after priming: %v", err)
|
||||
}
|
||||
|
||||
if !hasSpotifySource(sources) {
|
||||
for _, src := range sources {
|
||||
t.Logf("source after priming: ID=%s providerID=%s keyType=%s account=%s", src.ID, src.SourceProviderID, src.SourceKey.Type, src.SourceKey.Account)
|
||||
}
|
||||
|
||||
t.Fatalf("expected a SPOTIFY ConfiguredSource (providerID=%d) after priming", constants.SpotifyProviderID)
|
||||
}
|
||||
|
||||
// The speaker's on-device Sources.xml only refreshes when we tell it to —
|
||||
// without this notification storePreset keeps failing even though marge
|
||||
// already has the SPOTIFY source.
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && !notified.Load() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !notified.Load() {
|
||||
t.Errorf("speaker did not receive a sourcesUpdated /notification after priming")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrimeDeviceWithSpotify_SkipsWhenDeviceUnmapped ensures that priming a
|
||||
// device whose IP is not associated with any account does NOT fabricate a
|
||||
// source under the "default" account — the previous behavior would silently
|
||||
// pollute marge with sources for devices that never asked.
|
||||
func TestPrimeDeviceWithSpotify_SkipsWhenDeviceUnmapped(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerURL, _ := url.Parse(speakerTS.URL)
|
||||
speakerHostPort := speakerURL.Host
|
||||
|
||||
// Pre-seed a Spotify account but do NOT register any device.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
_ = os.MkdirAll(spotifyDir, 0o755)
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"access_token": "fresh-access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600)
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
// "default" account should have no SPOTIFY source added by us.
|
||||
sources, _ := ds.GetConfiguredSources("default", "")
|
||||
if hasSpotifySource(sources) {
|
||||
t.Errorf("priming an unmapped device wrote a SPOTIFY source under 'default' — should have been skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrimeDeviceWithSpotify_LiveMargeAccountUUIDWins covers the production
|
||||
// scenario the previous test didn't catch: a device whose datastore
|
||||
// ServiceDeviceInfo.AccountID is "default" (or stale) but whose live
|
||||
// :8090/info reports a real paired margeAccountUUID. The SPOTIFY source must
|
||||
// land under the paired account — that's the account marge.UpdatePreset
|
||||
// receives storePreset under, so writing anywhere else means the preset still
|
||||
// fails with "AddPreset - failed due to invalid SourceID".
|
||||
//
|
||||
// Mirrors setup.populateDeviceInfo's resolution order (datastore ← live /info)
|
||||
// rather than guessing.
|
||||
func TestPrimeDeviceWithSpotify_LiveMargeAccountUUIDWins(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
const (
|
||||
datastoreAccount = "default" // stale / fallback
|
||||
pairedAccount = "1111111" // live margeAccountUUID from /info
|
||||
deviceID = "DEVPAIR"
|
||||
)
|
||||
|
||||
// Fake speaker that serves both /info and the ZeroConf /zc.
|
||||
var speakerHost string
|
||||
|
||||
speakerTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/info"):
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?>`+
|
||||
`<info deviceID="`+deviceID+`">`+
|
||||
`<name>Paired Speaker</name><type>SoundTouch 20</type>`+
|
||||
`<margeAccountUUID>`+pairedAccount+`</margeAccountUUID>`+
|
||||
`</info>`)
|
||||
case r.URL.Path == "/notification":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8" ?><status>/notification</status>`)
|
||||
default:
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer speakerTS.Close()
|
||||
|
||||
speakerHostPort := strings.TrimPrefix(speakerTS.URL, "http://")
|
||||
speakerHost, _, _ = net.SplitHostPort(speakerHostPort)
|
||||
|
||||
// Register the device under the STALE account so the datastore lookup
|
||||
// would yield the wrong answer if used in isolation.
|
||||
devInfo := &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: datastoreAccount,
|
||||
Name: "Paired Speaker",
|
||||
IPAddress: speakerHost,
|
||||
}
|
||||
if err := ds.SaveDeviceInfo(datastoreAccount, deviceID, devInfo); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
// And make sure the paired account's device dir exists so
|
||||
// marge.AddSource can persist the source (it walks accounts/devices/...).
|
||||
if err := os.MkdirAll(filepath.Join(ds.AccountDevicesDir(pairedAccount), deviceID), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll paired dir: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed a Spotify account so priming has something to push.
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
_ = os.MkdirAll(spotifyDir, 0o755)
|
||||
accountsPayload := map[string]map[string]any{
|
||||
"spotify-user": {
|
||||
"user_id": "spotify-user",
|
||||
"display_name": "Spotify User",
|
||||
"access_token": "fresh-access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
"bose_secret": "bs-deadbeef",
|
||||
},
|
||||
}
|
||||
|
||||
accountsJSON, err := json.Marshal(accountsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal accounts: %v", err)
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), accountsJSON, 0o600)
|
||||
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
ss.SetEndpoints("http://127.0.0.1:1/token", "http://127.0.0.1:1")
|
||||
|
||||
if err := ss.Load(); err != nil {
|
||||
t.Fatalf("Load spotify accounts: %v", err)
|
||||
}
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
// Wire a real setup.Manager so resolvePairedAccount reaches /info.
|
||||
// HTTPGet uses the default net/http client, which hits the httptest
|
||||
// server directly via deviceIP=host:port.
|
||||
server.sm = setup.NewManager("http://localhost", ds, nil)
|
||||
|
||||
server.PrimeDeviceWithSpotify(speakerHostPort)
|
||||
|
||||
// SPOTIFY source must be under the PAIRED account, not the datastore one.
|
||||
pairedSources, err := ds.GetConfiguredSources(pairedAccount, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources(paired): %v", err)
|
||||
}
|
||||
|
||||
if !hasSpotifySource(pairedSources) {
|
||||
t.Errorf("expected SPOTIFY source under paired account %s, got %d sources", pairedAccount, len(pairedSources))
|
||||
}
|
||||
|
||||
// And it must NOT have been written under the stale datastore account.
|
||||
staleSources, _ := ds.GetConfiguredSources(datastoreAccount, deviceID)
|
||||
if hasSpotifySource(staleSources) {
|
||||
t.Errorf("SPOTIFY source unexpectedly written under stale datastore account %s — should follow live margeAccountUUID", datastoreAccount)
|
||||
}
|
||||
}
|
||||
|
||||
func hasSpotifySource(sources []models.ConfiguredSource) bool {
|
||||
for _, src := range sources {
|
||||
if src.SourceProviderID == "15" || src.SourceKey.Type == constants.ProviderSpotify {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -3,19 +3,24 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
@@ -659,13 +664,123 @@ func (s *Server) PrimeDeviceWithSpotify(deviceIP string) {
|
||||
|
||||
log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", deviceIP, username)
|
||||
|
||||
// Register the SPOTIFY source in our marge datastore before pushing credentials.
|
||||
// Without this, storePreset later fails with "AddPreset - failed due to invalid SourceID"
|
||||
// because marge.UpdatePreset can't match SourceID="SPOTIFY" against any ConfiguredSource.
|
||||
s.registerSpotifySourceForDevice(deviceIP, accounts)
|
||||
|
||||
if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
// addUser may return a benign 404+empty-body no-op when the speaker
|
||||
// already has the activeUser set. The zeroconf-level log already
|
||||
// recorded the specifics; here we just upgrade the watchdog's view to
|
||||
// "primed" since marge holds the authoritative SPOTIFY source.
|
||||
if errors.Is(err, spotify.ErrAddUserNoOp) {
|
||||
log.Printf("[Spotify Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", deviceIP)
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Successfully primed %s", deviceIP)
|
||||
}
|
||||
}
|
||||
|
||||
// registerSpotifySourceForDevice writes a SPOTIFY ConfiguredSource into the marge
|
||||
// datastore under the device's currently-paired account. No-op (with a log
|
||||
// message) if the device can't be resolved to an account — falling back to
|
||||
// "default" here would risk polluting an unrelated account's source list, and
|
||||
// any storePreset the device sends will be under its real paired account anyway.
|
||||
func (s *Server) registerSpotifySourceForDevice(deviceIP string, accounts []spotify.Account) {
|
||||
host := deviceIP
|
||||
if h, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
host = h
|
||||
}
|
||||
|
||||
accountID, deviceID := s.resolvePairedAccount(deviceIP, host)
|
||||
if accountID == "" {
|
||||
log.Printf("[Spotify Watchdog] No paired account for %s yet — skipping marge source registration", deviceIP)
|
||||
return
|
||||
}
|
||||
|
||||
registered := false
|
||||
|
||||
for _, acc := range accounts {
|
||||
credential := acc.BoseSecret
|
||||
if credential == "" {
|
||||
credential = acc.AccessToken
|
||||
}
|
||||
|
||||
if _, err := marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.SpotifyProviderID), credential, "token_version_3", acc.DisplayName); err != nil {
|
||||
log.Printf("[Spotify Watchdog] Failed to register Spotify source for account %s: %v", accountID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("[Spotify Watchdog] Registered Spotify source %s for account %s (device %s)", acc.UserID, accountID, deviceID)
|
||||
|
||||
registered = true
|
||||
}
|
||||
|
||||
// Tell the speaker its sources list changed so it re-fetches from marge.
|
||||
// Without this its on-device Sources.xml stays stale until something else
|
||||
// triggers a sync — which leaves storePreset failing with
|
||||
// "AddPreset - failed due to invalid SourceID" even though our marge
|
||||
// datastore already has the SPOTIFY entry.
|
||||
if registered && deviceID != "" {
|
||||
c := client.NewClientFromHost(deviceIP)
|
||||
if err := c.NotifySourcesUpdated(deviceID); err != nil {
|
||||
log.Printf("[Spotify Watchdog] sourcesUpdated notification for %s failed: %v", deviceIP, err)
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] Notified %s to re-sync sources (deviceID=%s)", deviceIP, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePairedAccount returns the device's currently-paired account ID and its
|
||||
// canonical deviceID. It prefers the live :8090/info margeAccountUUID (matches
|
||||
// what the device will actually send on storePreset) and falls back to the
|
||||
// datastore record. Mirrors setup.populateDeviceInfo's resolution order so
|
||||
// priming and migration agree on which account a device belongs to.
|
||||
//
|
||||
// deviceIP is the original input (may carry a :port for tests); host is the
|
||||
// bare host for datastore IPAddress matching.
|
||||
func (s *Server) resolvePairedAccount(deviceIP, host string) (accountID, deviceID string) {
|
||||
if devInfo := s.findExistingDeviceInfoByIP(host); devInfo != nil {
|
||||
accountID = devInfo.AccountID
|
||||
deviceID = devInfo.DeviceID
|
||||
}
|
||||
|
||||
if s.sm != nil {
|
||||
if info, err := s.sm.GetLiveDeviceInfo(deviceIP); err == nil {
|
||||
if info.MargeAccountUUID != "" {
|
||||
accountID = info.MargeAccountUUID
|
||||
}
|
||||
|
||||
if info.DeviceID != "" {
|
||||
deviceID = info.DeviceID
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Spotify Watchdog] live /info lookup for %s failed: %v (falling back to datastore account=%q)", deviceIP, err, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
return accountID, deviceID
|
||||
}
|
||||
|
||||
// findExistingDeviceInfoByIP looks up a device record by IP address across all accounts.
|
||||
func (s *Server) findExistingDeviceInfoByIP(ip string) *models.ServiceDeviceInfo {
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range allDevices {
|
||||
if allDevices[i].IPAddress == ip {
|
||||
return &allDevices[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error {
|
||||
var zcURL string
|
||||
if _, _, err := net.SplitHostPort(deviceIP); err == nil {
|
||||
@@ -701,7 +816,11 @@ func (s *Server) PrimeDeviceWithAmazon(deviceIP string) {
|
||||
log.Printf("[Amazon Watchdog] Proactively priming %s with Amazon user %s", deviceIP, username)
|
||||
|
||||
if err := s.pushAmazonTokenToDevice(deviceIP, username, accessToken); err != nil {
|
||||
log.Printf("[Amazon Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
if errors.Is(err, amazon.ErrAddUserNoOp) {
|
||||
log.Printf("[Amazon Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", deviceIP)
|
||||
} else {
|
||||
log.Printf("[Amazon Watchdog] Failed to prime %s: %v", deviceIP, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Amazon Watchdog] Successfully primed %s", deviceIP)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ package spotify
|
||||
|
||||
import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf"
|
||||
|
||||
// ErrAddUserNoOp re-exports zeroconf.ErrAddUserNoOp so callers in the spotify
|
||||
// package don't need a direct dependency on the zeroconf package to recognise
|
||||
// the benign-no-op sentinel.
|
||||
var ErrAddUserNoOp = zeroconf.ErrAddUserNoOp
|
||||
|
||||
// ZeroConfGetInfo fetches the speaker's DH public key via GET ?action=getInfo.
|
||||
func ZeroConfGetInfo(zcBaseURL string) ([]byte, error) {
|
||||
return zeroconf.GetInfo(zcBaseURL)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,15 @@ import (
|
||||
// (AUTHENTICATION_SPOTIFY_TOKEN = 4). Both Spotify and Amazon use this value.
|
||||
const AuthTypeOAuthToken uint64 = 4
|
||||
|
||||
// ErrAddUserNoOp signals a benign 404-with-empty-body reply from the speaker's
|
||||
// ?action=addUser endpoint. SoundTouch firmware uses that exact response shape
|
||||
// to mean "no transition required" — typically because the requested
|
||||
// activeUser is already the active one. It is NOT a credential or transport
|
||||
// failure; the speaker silently kept its current state. Callers that have
|
||||
// already written the authoritative source record to marge (the path
|
||||
// presets/playback actually go through) should treat this as success.
|
||||
var ErrAddUserNoOp = errors.New("zeroconf: addUser no-op (speaker already in target state)")
|
||||
|
||||
// dhPrimeBytes is the 768-bit MODP Group 1 prime from RFC 2409 §6.1.
|
||||
var dhPrimeBytes = []byte{
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
@@ -333,12 +344,56 @@ func PushCredentials(zcBaseURL, username, accessToken string) error {
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if isAddUserNoOp(resp.StatusCode, body) {
|
||||
logAddUserNoOp("DH", base, username, resp)
|
||||
|
||||
return ErrAddUserNoOp
|
||||
}
|
||||
|
||||
logAddUserFailure("DH", base, username, resp, body)
|
||||
|
||||
return fmt.Errorf("pushCredentials: addUser status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAddUserNoOp recognises the narrow firmware pattern (status 404, empty body)
|
||||
// that signals "no transition required". Anything else — including 404 with a
|
||||
// body, or any other non-2xx — falls through to the real failure path so we
|
||||
// don't silently swallow genuine errors.
|
||||
func isAddUserNoOp(status int, body []byte) bool {
|
||||
return status == http.StatusNotFound && len(bytes.TrimSpace(body)) == 0
|
||||
}
|
||||
|
||||
// logAddUserNoOp emits a single line marking the benign no-op explicitly —
|
||||
// kept visible (not Debug-level) so the operator can correlate it with priming
|
||||
// runs, but worded so it's clearly not a failure.
|
||||
func logAddUserNoOp(path string, base *url.URL, username string, resp *http.Response) {
|
||||
log.Printf("[ZeroConf] addUser produced expected no-op via %s path (speaker already has activeUser=%q or equivalent state): url=%s status=%d body=<empty> — marge source registration is authoritative for preset/playback",
|
||||
path, username, withAction(base, "addUser"), resp.StatusCode)
|
||||
}
|
||||
|
||||
// logAddUserFailure emits a single diagnostic line capturing what the speaker
|
||||
// said about an `?action=addUser` rejection. Bose firmware often returns 4xx
|
||||
// with an empty body, so the headers (libspotify version, content-type,
|
||||
// content-length) are the only clue about whether the speaker refused the
|
||||
// transition, the credential, or the action entirely. Kept verbose on purpose —
|
||||
// these failures are rare and worth grepping for.
|
||||
func logAddUserFailure(path string, base *url.URL, username string, resp *http.Response, body []byte) {
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
cl := resp.Header.Get("Content-Length")
|
||||
server := resp.Header.Get("Server")
|
||||
|
||||
bodySummary := strings.TrimSpace(string(body))
|
||||
if bodySummary == "" {
|
||||
bodySummary = "<empty>"
|
||||
}
|
||||
|
||||
log.Printf("[ZeroConf] addUser rejected via %s path: url=%s userName=%q status=%d server=%q content-type=%q content-length=%q body=%q",
|
||||
path, withAction(base, "addUser"), username, resp.StatusCode, server, ct, cl, bodySummary)
|
||||
}
|
||||
|
||||
// pushSimplifiedToken is the fallback for firmware that does not support DH
|
||||
// key exchange. It sends the raw OAuth access token directly as the blob.
|
||||
func pushSimplifiedToken(zcBaseURL, username, accessToken string) error {
|
||||
@@ -364,6 +419,14 @@ func pushSimplifiedToken(zcBaseURL, username, accessToken string) error {
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if isAddUserNoOp(resp.StatusCode, body) {
|
||||
logAddUserNoOp("simplified", base, username, resp)
|
||||
|
||||
return ErrAddUserNoOp
|
||||
}
|
||||
|
||||
logAddUserFailure("simplified", base, username, resp, body)
|
||||
|
||||
return fmt.Errorf("pushSimplifiedToken: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package zeroconf
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -363,3 +364,112 @@ func TestValidateZcBaseURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushCredentials_AddUserNoOp covers the firmware quirk we observed in
|
||||
// production: ?action=addUser sometimes returns 404 with an empty body when
|
||||
// the speaker already has the requested user as its active one. That is NOT a
|
||||
// failure — the speaker silently kept its state. PushCredentials must signal
|
||||
// this via ErrAddUserNoOp so the watchdog can demote it from "Failed to prime"
|
||||
// to a benign success.
|
||||
func TestPushCredentials_AddUserNoOp(t *testing.T) {
|
||||
_, speakerPublicBytes, err := GenerateDHKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("speaker keygen: %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": 101,
|
||||
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
|
||||
})
|
||||
case "addUser":
|
||||
// Firmware no-op: 404 + empty body, no Server / Content-Type header.
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err = PushCredentials(srv.URL+"/zc", "gesellix", "fresh-access-token")
|
||||
if !errors.Is(err, ErrAddUserNoOp) {
|
||||
t.Fatalf("PushCredentials: got %v, want ErrAddUserNoOp", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushCredentials_AddUserNoOpInSimplifiedPath asserts the same narrow
|
||||
// pattern is recognised on the simplified-token fallback (firmware that
|
||||
// 404s getInfo entirely).
|
||||
func TestPushCredentials_AddUserNoOpInSimplifiedPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
http.Error(w, "not supported", http.StatusNotFound)
|
||||
case "addUser":
|
||||
w.WriteHeader(http.StatusNotFound) // empty body
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushCredentials(srv.URL+"/zc", "gesellix", "raw-access-token")
|
||||
if !errors.Is(err, ErrAddUserNoOp) {
|
||||
t.Fatalf("PushCredentials (simplified path): got %v, want ErrAddUserNoOp", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushCredentials_AddUserRealError_NotMisclassified guards the narrowness
|
||||
// of isAddUserNoOp: a 404 *with* a body (or any non-404 error) must still
|
||||
// surface as a regular error, not the benign sentinel. Otherwise we'd silently
|
||||
// swallow genuine credential rejections that happen to come back as 4xx.
|
||||
func TestPushCredentials_AddUserRealError_NotMisclassified(t *testing.T) {
|
||||
_, speakerPublicBytes, err := GenerateDHKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("speaker keygen: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"404 with body should NOT be no-op", http.StatusNotFound, "spotifyError=12 invalid_token"},
|
||||
{"400 empty body should NOT be no-op", http.StatusBadRequest, ""},
|
||||
{"500 empty body should NOT be no-op", http.StatusInternalServerError, ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("action") {
|
||||
case "getInfo":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": 101,
|
||||
"publicKey": base64.StdEncoding.EncodeToString(speakerPublicBytes),
|
||||
})
|
||||
case "addUser":
|
||||
w.WriteHeader(tc.status)
|
||||
if tc.body != "" {
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := PushCredentials(srv.URL+"/zc", "gesellix", "fresh-access-token")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if errors.Is(err, ErrAddUserNoOp) {
|
||||
t.Errorf("got ErrAddUserNoOp, want a real failure for status=%d body=%q", tc.status, tc.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user