diff --git a/CHANGELOG.md b/CHANGELOG.md index bfef86d..66fefdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Fixed +- Explain SMAPI API-client identity rejections and suggest supported playback alternatives without exposing service response bodies. Thanks @DonnieFi. - Label initial `watch` notifications with the correct service even when they arrive before subscription setup finishes. ### Changed diff --git a/README.md b/README.md index 249b1b5..6c8faec 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,24 @@ YouTube: - Sonos plays the resolved temporary audio stream directly; long videos may expire and need resolving again. - For most YouTube playback, prefer `sonos play-url`: it proxies the stream through your machine, transcodes to MP3, and avoids Sonos having to fetch YouTube's temporary media URL itself. +YouTube Music account browsing is separate from playback control. Google may +reject `sonos auth smapi begin` or `sonos auth smapi complete` with +`--service "YouTube Music"` and HTTP 403 +because the caller lacks a registered API client identity. Reauthorizing the +account in the Sonos app does not supply that identity to sonoscli. Music started +in the app can still be inspected and controlled with `status`, `play`, `pause`, +`next`, and queue commands. + +For a playable public YouTube or YouTube Music URL, try the URL-streaming path +instead (requires `yt-dlp` and `ffmpeg`; this is not SMAPI account browsing): + +```bash +sonos play-url --name "Kitchen" "https://www.youtube.com/watch?v=" +``` + +The computer running the proxy must remain on and reachable by the speaker while +playing. This starts playback immediately; it does not queue the URL as "play next". + Smart URL streaming: - Install `yt-dlp` and `ffmpeg` for `sonos play-url`. - `play-url` resolves common media pages, pipes `yt-dlp` sources into `ffmpeg`, transcodes to a local MP3 stream, sends the resolved title/provider to Sonos metadata, and exits the proxy when playback ends or goes idle. diff --git a/docs/proof/youtube-music-auth.md b/docs/proof/youtube-music-auth.md new file mode 100644 index 0000000..93c82ff --- /dev/null +++ b/docs/proof/youtube-music-auth.md @@ -0,0 +1,57 @@ +# YouTube Music authentication diagnostic: live CLI proof + +Captured 2026-10-20 against a real Sonos speaker and its advertised YouTube Music service, not a mock HTTP endpoint. No playback or volume commands were issued. Only the speaker address in the command is redacted; the error output below is unchanged. No linking code was returned. + +## Before: upstream main b7850a276c8c549532f3fcd88c29dad68facf2b4 + +Built the actual CLI with `go build -o /tmp/sonos-pr29-base-bin ./cmd/sonos` from a detached worktree at the above revision. + +```console +$ /tmp/sonos-pr29-base-bin auth smapi begin --service 'YouTube Music' --ip +Error: smapi soap http 403 Forbidden +exit: 1 +``` + +## After: PR branch with supported-next-action fix + +Built the actual CLI with `go build -o /tmp/sonos-pr29-head ./cmd/sonos` from this revision's production code. The installed CLI was not replaced. + +```console +$ /tmp/sonos-pr29-head auth smapi begin --service 'YouTube Music' --ip +Error: smapi soap http 403 Forbidden: service requires a registered API client identity (API key or equivalent); account linking alone cannot resolve this; start playback in the Sonos app and control it with sonos, or use sonos play-url with a public URL (playback only, not account authentication) +exit: 1 +``` + +The real service still rejects authentication. The change is the actionable, body-safe error message, not restored account access. Public-URL playback and Sonos-app-started playback are separate from account authentication. + +## Base-fail / head-pass regression + +Copied this branch's `internal/sonos/smapi_auth_error_test.go` into the detached base worktree (no production changes). Ran on both revisions: + +```sh +go test ./internal/sonos -run TestSMAPI_BeginAuthentication_APIClientIdentityError -count=1 +``` + +Base output (exit 1): + +```text +--- FAIL: TestSMAPI_BeginAuthentication_APIClientIdentityError (0.00s) + --- FAIL: TestSMAPI_BeginAuthentication_APIClientIdentityError/unregistered_caller (0.00s) + smapi_auth_error_test.go:37: API identity hint = false, want true: smapi soap http 403 Forbidden +FAIL +FAIL github.com/steipete/sonoscli/internal/sonos 0.007s +FAIL +``` + +Head output (exit 0; run with `-v`): + +```text +--- PASS: TestSMAPI_BeginAuthentication_APIClientIdentityError (0.00s) + --- PASS: TestSMAPI_BeginAuthentication_APIClientIdentityError/unregistered_caller (0.00s) + --- PASS: TestSMAPI_BeginAuthentication_APIClientIdentityError/other_permission_error (0.00s) + --- PASS: TestSMAPI_BeginAuthentication_APIClientIdentityError/non_JSON_response (0.00s) +PASS +ok github.com/steipete/sonoscli/internal/sonos 0.006s +``` + +Also passed on the patched branch: `go test ./...`, `go vet ./...`, and `git diff --check`. diff --git a/internal/sonos/smapi.go b/internal/sonos/smapi.go index 6651275..0a34bdd 100644 --- a/internal/sonos/smapi.go +++ b/internal/sonos/smapi.go @@ -560,12 +560,31 @@ func (c *SMAPIClient) smapiCall(ctx context.Context, method string, args map[str return nil, fmt.Errorf("smapi fault after refresh: %s: %s", fault2.FaultCode, fault2.FaultString) } } - return nil, fmt.Errorf("smapi soap http %s", resp2.Status) + return nil, smapiHTTPError(resp2.StatusCode, resp2.Status, raw2) } return nil, fmt.Errorf("smapi fault: %s: %s", fault.FaultCode, fault.FaultString) } - return nil, fmt.Errorf("smapi soap http %s", resp.Status) + return nil, smapiHTTPError(resp.StatusCode, resp.Status, raw) +} + +// Recognize API gateway rejection without exposing arbitrary service response +// bodies, which may contain account details or credentials. +func smapiHTTPError(statusCode int, status string, raw []byte) error { + if statusCode == http.StatusForbidden { + var payload struct { + Error struct { + Status string `json:"status"` + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(raw, &payload) == nil && + payload.Error.Status == "PERMISSION_DENIED" && + strings.Contains(payload.Error.Message, "Method doesn't allow unregistered callers") { + return fmt.Errorf("smapi soap http %s: service requires a registered API client identity (API key or equivalent); account linking alone cannot resolve this; start playback in the Sonos app and control it with sonos, or use sonos play-url with a public URL (playback only, not account authentication)", status) + } + } + return fmt.Errorf("smapi soap http %s", status) } func (c *SMAPIClient) buildCredentialsHeader(allowUnauthed bool) (string, error) { diff --git a/internal/sonos/smapi_auth_error_test.go b/internal/sonos/smapi_auth_error_test.go new file mode 100644 index 0000000..0f11f31 --- /dev/null +++ b/internal/sonos/smapi_auth_error_test.go @@ -0,0 +1,50 @@ +package sonos + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSMAPI_BeginAuthentication_APIClientIdentityError(t *testing.T) { + for _, tt := range []struct { + name string + status int + body string + wantHint bool + }{ + {"unregistered caller", http.StatusForbidden, `{"error":{"code":403,"status":"PERMISSION_DENIED","message":"Method doesn't allow unregistered callers (callers without established identity). Please use API Key or other form of API consumer identity to call this API. private account detail"}}`, true}, + {"other permission error", http.StatusForbidden, `{"error":{"status":"PERMISSION_DENIED","message":"private account detail"}}`, false}, + {"non JSON response", http.StatusForbidden, `private account detail`, false}, + {"other HTTP status", http.StatusUnauthorized, `{"error":{"status":"PERMISSION_DENIED","message":"Method doesn't allow unregistered callers. private account detail"}}`, false}, + {"other API status", http.StatusForbidden, `{"error":{"status":"UNAUTHENTICATED","message":"Method doesn't allow unregistered callers. private account detail"}}`, false}, + } { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + })) + defer srv.Close() + c := &SMAPIClient{ + httpClient: srv.Client(), + Service: MusicServiceDescriptor{ID: "284", Auth: MusicServiceAuthAppLink, SecureURI: srv.URL}, + HouseholdID: "test-household", DeviceID: "test-device", TokenStore: newMemTokenStore(), + } + _, err := c.BeginAuthentication(context.Background()) + if err == nil || !strings.Contains(err.Error(), http.StatusText(tt.status)) { + t.Fatalf("expected HTTP status in error, got %v", err) + } + if got := strings.Contains(err.Error(), "API client identity"); got != tt.wantHint { + t.Fatalf("API identity hint = %v, want %v: %v", got, tt.wantHint, err) + } + if tt.wantHint && (!strings.Contains(err.Error(), "Sonos app") || !strings.Contains(err.Error(), "sonos play-url") || !strings.Contains(err.Error(), "not account authentication")) { + t.Fatalf("missing supported playback alternative and authentication distinction: %v", err) + } + if strings.Contains(err.Error(), "private account detail") { + t.Fatalf("error exposed response body: %v", err) + } + }) + } +}