fix: recover auth import and device transfer
This commit is contained in:
parent
68653641ad
commit
c613e96ed2
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user