diff --git a/CHANGELOG.md b/CHANGELOG.md index dff80ff..2ae5ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 39b3822..9ef6260 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/spec.md b/docs/spec.md index 829b251..e0c09a5 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -42,6 +42,7 @@ spogo [global flags] [args] - `--browser-profile ` - `--cookie-path ` - `--domain ` 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 ` @@ -112,6 +113,7 @@ spogo [global flags] [args] - `spogo device list` - `spogo device set ` + - falls back to Web API transfer when Connect state has no origin device ## Output contract diff --git a/internal/cookies/source.go b/internal/cookies/source.go index 5bed47c..ca5e6bb 100644 --- a/internal/cookies/source.go +++ b/internal/cookies/source.go @@ -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) diff --git a/internal/cookies/source_test.go b/internal/cookies/source_test.go index bfac7fd..888f317 100644 --- a/internal/cookies/source_test.go +++ b/internal/cookies/source_test.go @@ -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{ diff --git a/internal/spotify/connect_commands.go b/internal/spotify/connect_commands.go index 1abd5d3..bc920a1 100644 --- a/internal/spotify/connect_commands.go +++ b/internal/spotify/connect_commands.go @@ -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 == "" { diff --git a/internal/spotify/connect_playback_test.go b/internal/spotify/connect_playback_test.go index fad8cd0..6bdec73 100644 --- a/internal/spotify/connect_playback_test.go +++ b/internal/spotify/connect_playback_test.go @@ -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