fix: recover auth import and device transfer

This commit is contained in:
Peter Steinberger 2026-03-08 05:17:49 +00:00
parent 68653641ad
commit c613e96ed2
7 changed files with 240 additions and 41 deletions

View File

@ -5,6 +5,8 @@
- Add `auth paste`, wire `--no-input`, and improve cookie diagnostics/cleanup (`#5`, thanks @im-zayan)
- Add `play --shuffle`, Connect library/playlist support, and context-aware Connect play payloads (`#15`, thanks @StandardGage)
- Fix Connect track artist extraction for nested artist containers and minimal artist fragments (`#7`, thanks @joelbdavies)
- Fix silent `auth import` failures by retrying Spotify auth cookie lookup across related hosts and surfacing browser warnings (`#13`)
- Fix `device set` when Connect state has no origin device by falling back to Web API transfer (`#8`)
## 0.3.0 - 2026-03-08

View File

@ -97,6 +97,7 @@ spogo auth import --browser chrome
```
Defaults: Chrome + Default profile. Cookies are stored under your config directory (per profile).
If import still fails, `spogo` now surfaces browser-store warnings instead of only printing `no cookies found`.
### Manual cookie paste (WSL fallback)

View File

@ -42,6 +42,7 @@ spogo [global flags] <command> [args]
- `--browser-profile <name>`
- `--cookie-path <file>`
- `--domain <host>` default `spotify.com`
- when browser reads fail, surface underlying browser-store warnings
- `spogo auth paste`
- reads cookie values from stdin (prompts when interactive)
- `--cookie-path <file>`
@ -112,6 +113,7 @@ spogo [global flags] <command> [args]
- `spogo device list`
- `spogo device set <name|id>`
- falls back to Web API transfer when Connect state has no origin device
## Output contract

View File

@ -3,8 +3,10 @@ package cookies
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
@ -13,6 +15,8 @@ import (
var readCookies = sweetcookie.Get
var authCookieNames = []string{"sp_dc", "sp_key", "sp_t"}
// SetReadCookies overrides the internal cookie reader and returns a restore func.
// Intended for tests.
func SetReadCookies(fn func(context.Context, sweetcookie.Options) (sweetcookie.Result, error)) func() {
@ -40,50 +44,20 @@ type FileSource struct {
}
func (s BrowserSource) Cookies(ctx context.Context) ([]*http.Cookie, error) {
domain := strings.TrimSpace(s.Domain)
if domain == "" {
domain = "spotify.com"
}
host := domain
if strings.Contains(domain, "://") {
if parsed, err := url.Parse(domain); err == nil && parsed.Hostname() != "" {
host = parsed.Hostname()
}
}
url := "https://" + host
origins := []string{}
if strings.Contains(host, "spotify.com") {
if host != "open.spotify.com" {
origins = append(origins, "https://open.spotify.com")
}
if host != "spotify.com" {
origins = append(origins, "https://spotify.com")
}
}
opts := sweetcookie.Options{
URL: url,
Origins: origins,
Mode: sweetcookie.ModeFirst,
Timeout: 5 * time.Second,
}
if s.Browser != "" {
browser := sweetcookie.Browser(strings.ToLower(s.Browser))
opts.Browsers = []sweetcookie.Browser{browser}
if s.Profile != "" {
opts.Profiles = map[sweetcookie.Browser]string{browser: s.Profile}
}
} else if s.Profile != "" {
opts.Profiles = map[sweetcookie.Browser]string{}
for _, browser := range sweetcookie.DefaultBrowsers() {
opts.Profiles[browser] = s.Profile
}
}
result, err := readCookies(ctx, opts)
result, err := readCookies(ctx, s.cookieOptions(false))
if err != nil {
return nil, err
}
if len(result.Cookies) == 0 && s.shouldRetryAcrossHosts() {
retry, retryErr := readCookies(ctx, s.cookieOptions(true))
if retryErr != nil {
return nil, retryErr
}
result.Cookies = retry.Cookies
result.Warnings = append(result.Warnings, retry.Warnings...)
}
if len(result.Cookies) == 0 {
return nil, errors.New("no cookies found")
return nil, browserCookiesNotFoundError(result.Warnings)
}
ret := make([]*http.Cookie, 0, len(result.Cookies))
for _, c := range result.Cookies {
@ -103,6 +77,101 @@ func (s BrowserSource) Cookies(ctx context.Context) ([]*http.Cookie, error) {
return ret, nil
}
func (s BrowserSource) cookieOptions(allowAllHosts bool) sweetcookie.Options {
host := s.host()
opts := sweetcookie.Options{
Mode: sweetcookie.ModeFirst,
Timeout: 5 * time.Second,
Names: authCookieNames,
}
if allowAllHosts {
opts.AllowAllHosts = true
} else {
opts.URL = "https://" + host
opts.Origins = spotifyOrigins(host)
}
if s.Browser != "" {
browser := sweetcookie.Browser(strings.ToLower(s.Browser))
opts.Browsers = []sweetcookie.Browser{browser}
if s.Profile != "" {
opts.Profiles = map[sweetcookie.Browser]string{browser: s.Profile}
}
} else if s.Profile != "" {
opts.Profiles = map[sweetcookie.Browser]string{}
for _, browser := range sweetcookie.DefaultBrowsers() {
opts.Profiles[browser] = s.Profile
}
}
return opts
}
func (s BrowserSource) host() string {
domain := strings.TrimSpace(s.Domain)
if domain == "" {
domain = "spotify.com"
}
if strings.Contains(domain, "://") {
if parsed, err := url.Parse(domain); err == nil && parsed.Hostname() != "" {
return parsed.Hostname()
}
}
return domain
}
func (s BrowserSource) shouldRetryAcrossHosts() bool {
host := normalizeCookieHost(s.host())
return host == "spotify.com" || strings.HasSuffix(host, ".spotify.com")
}
func spotifyOrigins(host string) []string {
host = normalizeCookieHost(host)
if host == "" {
return nil
}
if !strings.Contains(host, "spotify.com") {
return nil
}
origins := []string{}
for _, candidate := range []string{"spotify.com", "open.spotify.com", "accounts.spotify.com"} {
if host == candidate {
continue
}
origins = append(origins, "https://"+candidate)
}
return origins
}
func normalizeCookieHost(host string) string {
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(host), "."))
}
func browserCookiesNotFoundError(warnings []string) error {
warnings = compactWarnings(warnings)
if len(warnings) == 0 {
return errors.New("no cookies found")
}
return fmt.Errorf("no cookies found; %s", strings.Join(warnings, "; "))
}
func compactWarnings(warnings []string) []string {
out := make([]string, 0, len(warnings))
for _, warning := range warnings {
warning = strings.TrimSpace(warning)
if warning == "" {
continue
}
out = append(out, warning)
}
if len(out) == 0 {
return nil
}
out = slices.Compact(out)
if len(out) > 3 {
return out[:3]
}
return out
}
func (s FileSource) Cookies(ctx context.Context) ([]*http.Cookie, error) {
_ = ctx
return Read(s.Path)

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
@ -39,6 +40,40 @@ func TestBrowserSourceNoCookies(t *testing.T) {
}
}
func TestBrowserSourceRetriesAcrossSpotifyHosts(t *testing.T) {
var calls []sweetcookie.Options
restore := SetReadCookies(func(ctx context.Context, opts sweetcookie.Options) (sweetcookie.Result, error) {
calls = append(calls, opts)
if len(calls) == 1 {
return sweetcookie.Result{}, nil
}
return sweetcookie.Result{
Cookies: []sweetcookie.Cookie{{Name: "sp_dc", Value: "token", Domain: ".accounts.spotify.com"}},
}, nil
})
defer restore()
src := BrowserSource{Browser: "chrome", Domain: "spotify.com"}
cookies, err := src.Cookies(context.Background())
if err != nil {
t.Fatalf("cookies: %v", err)
}
if len(cookies) != 1 || cookies[0].Name != "sp_dc" {
t.Fatalf("unexpected cookies: %#v", cookies)
}
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(calls))
}
if calls[0].AllowAllHosts {
t.Fatalf("expected filtered first pass")
}
if !calls[1].AllowAllHosts {
t.Fatalf("expected allow-all fallback")
}
if len(calls[1].Names) != len(authCookieNames) {
t.Fatalf("expected auth cookie allowlist")
}
}
func TestSetReadCookies(t *testing.T) {
restore := SetReadCookies(nil)
restore()
@ -59,6 +94,45 @@ func TestBrowserSourceError(t *testing.T) {
}
}
func TestBrowserSourceNoCookiesIncludesWarnings(t *testing.T) {
restore := SetReadCookies(func(ctx context.Context, opts sweetcookie.Options) (sweetcookie.Result, error) {
return sweetcookie.Result{Warnings: []string{"sweetcookie: chrome cookie store not found"}}, nil
})
defer restore()
src := BrowserSource{Browser: "chrome", Domain: "spotify.com"}
_, err := src.Cookies(context.Background())
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "chrome cookie store not found") {
t.Fatalf("expected warning in error, got %v", err)
}
}
func TestBrowserSourceUsesSpotifyOrigins(t *testing.T) {
var got sweetcookie.Options
restore := SetReadCookies(func(ctx context.Context, opts sweetcookie.Options) (sweetcookie.Result, error) {
got = opts
return sweetcookie.Result{
Cookies: []sweetcookie.Cookie{{Name: "sp_dc", Value: "token", Domain: ".spotify.com"}},
}, nil
})
defer restore()
src := BrowserSource{Browser: "chrome", Domain: "spotify.com"}
if _, err := src.Cookies(context.Background()); err != nil {
t.Fatalf("expected cookies: %v", err)
}
if len(got.Origins) != 2 {
t.Fatalf("expected 2 spotify origins, got %v", got.Origins)
}
if got.Origins[0] != "https://open.spotify.com" && got.Origins[1] != "https://open.spotify.com" {
t.Fatalf("expected open.spotify.com origin, got %v", got.Origins)
}
if got.Origins[0] != "https://accounts.spotify.com" && got.Origins[1] != "https://accounts.spotify.com" {
t.Fatalf("expected accounts.spotify.com origin, got %v", got.Origins)
}
}
func TestBrowserSourceDefaultDomain(t *testing.T) {
restore := SetReadCookies(func(ctx context.Context, opts sweetcookie.Options) (sweetcookie.Result, error) {
return sweetcookie.Result{

View File

@ -23,7 +23,7 @@ func (c *ConnectClient) transfer(ctx context.Context, deviceID string) error {
return withConnectStateErr(ctx, c, func(state connectState) error {
fromID := connectTransferSourceID(state)
if fromID == "" {
return errors.New("missing origin device id")
return c.transferViaWebAPI(ctx, deviceID)
}
return c.sendConnectCommand(ctx, fmt.Sprintf("%s/connect/transfer/from/%s/to/%s", connectStateBase, fromID, deviceID), map[string]any{
"transfer_options": map[string]any{
@ -34,6 +34,12 @@ func (c *ConnectClient) transfer(ctx context.Context, deviceID string) error {
})
}
func (c *ConnectClient) transferViaWebAPI(ctx context.Context, deviceID string) error {
return withWebFallback(c, func(web *Client) error {
return web.Transfer(ctx, deviceID)
})
}
func (c *ConnectClient) play(ctx context.Context, uri string) error {
return withConnectStateErr(ctx, c, func(state connectState) error {
if uri == "" {

View File

@ -142,6 +142,51 @@ func TestConnectPlaybackActiveDeviceFromDevices(t *testing.T) {
}
}
func TestConnectTransferFallsBackToWebAPIWithoutOriginDevice(t *testing.T) {
statePayload := map[string]any{
"devices": map[string]any{
"device-1": map[string]any{
"name": "Desk",
"device_type": "computer",
},
},
"player_state": map[string]any{
"is_paused": true,
},
}
var sawWebTransfer bool
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/devices/hobs_"):
return jsonResponse(http.StatusOK, statePayload), nil
case req.Method == http.MethodPut && req.URL.Path == "/v1/me/player":
sawWebTransfer = true
return textResponse(http.StatusNoContent, ""), nil
case req.Method == http.MethodPost:
t.Fatalf("unexpected connect command: %s", req.URL.Path)
return nil, nil
default:
return textResponse(http.StatusNotFound, "missing"), nil
}
})
client := newRegisteredConnectClientForTests(transport)
webClient, err := NewClient(Options{
TokenProvider: staticTokenProvider{},
HTTPClient: client.client,
})
if err != nil {
t.Fatalf("new web client: %v", err)
}
client.web = webClient
if err := client.Transfer(context.Background(), "device-1"); err != nil {
t.Fatalf("transfer: %v", err)
}
if !sawWebTransfer {
t.Fatalf("expected web transfer fallback")
}
}
func TestSendPlayerCommandMissingDevice(t *testing.T) {
client := newConnectClientForTests(roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return textResponse(http.StatusOK, ""), nil