Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Env overrides:

Commands:

- `auth status|import|clear`
- `auth status|import|paste|clear`
- `search track|album|artist|playlist|show|episode`
- `track info`, `album info`, `artist info`, `playlist info`, `show info`, `episode info`
- `play [<id|url>] [--type ...]`, `pause`, `next`, `prev`, `seek`, `volume`, `shuffle`, `repeat`, `status`
Expand All @@ -98,6 +98,24 @@ spogo auth import --browser chrome

Defaults: Chrome + Default profile. Cookies are stored under your config directory (per profile).

### Manual cookie paste (WSL fallback)

If WSL cookie import/decryption is broken, paste cookies from Chrome DevTools:

1) Developer Tools -> Application tab -> Cookies -> `https://open.spotify.com`
2) Copy `sp_dc` (required), `sp_key` (optional), `sp_t` (recommended for connect playback)
3) Run:

```bash
spogo auth paste
```

Non-interactive:

```bash
printf '%s\n%s\n' "sp_dc=..." "sp_t=..." | spogo auth paste --no-input
```

## Auto engine notes

- `auto` tries connect first, then falls back to web on unsupported features or rate limits.
Expand Down
5 changes: 5 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ spogo [global flags] <command> [args]
- `--browser-profile <name>`
- `--cookie-path <file>`
- `--domain <host>` default `spotify.com`
- `spogo auth paste`
- reads cookie values from stdin (prompts when interactive)
- `--cookie-path <file>`
- `--domain <suffix>` default `spotify.com`
- `--path <path>` default `/`
- `spogo auth clear`

### search
Expand Down
1 change: 1 addition & 0 deletions internal/app/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Settings struct {
Quiet bool
Verbose bool
Debug bool
NoInput bool
}

type Context struct {
Expand Down
41 changes: 41 additions & 0 deletions internal/app/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,47 @@ func TestNewContextLoadsProfile(t *testing.T) {
}
}

func TestNewContextUsesDefaultPath(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)

ctx, err := NewContext(Settings{Format: output.FormatPlain})
if err != nil {
t.Fatalf("new context: %v", err)
}
want := filepath.Join(dir, "spogo", "config.toml")
if ctx.ConfigPath != want {
t.Fatalf("expected config path %q, got %q", want, ctx.ConfigPath)
}
if ctx.ProfileKey == "" {
t.Fatalf("expected profile key")
}
}

func TestNewContextAppliesSettingsOverrides(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
cfg := config.Default()
cfg.SetProfile("default", config.Profile{Market: "DE", Language: "de"})
if err := config.Save(path, cfg); err != nil {
t.Fatalf("save: %v", err)
}
ctx, err := NewContext(Settings{
ConfigPath: path,
Format: output.FormatPlain,
Market: "US",
Language: "en",
Device: "device",
Engine: "web",
})
if err != nil {
t.Fatalf("new context: %v", err)
}
if ctx.Profile.Market != "US" || ctx.Profile.Language != "en" || ctx.Profile.Device != "device" || ctx.Profile.Engine != "web" {
t.Fatalf("unexpected profile: %#v", ctx.Profile)
}
}

func TestResolveCookiePath(t *testing.T) {
ctx := &Context{ConfigPath: "/tmp/spogo/config.toml", ProfileKey: "default"}
path := ctx.ResolveCookiePath()
Expand Down
192 changes: 179 additions & 13 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package cli

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"os/exec"
"net/url"
"os"
"strings"

"github.com/mattn/go-isatty"
"github.com/steipete/spogo/internal/app"
"github.com/steipete/spogo/internal/cookies"
"github.com/steipete/spogo/internal/output"
Expand All @@ -15,6 +20,7 @@ import (
type AuthCmd struct {
Status AuthStatusCmd `kong:"cmd,help='Show cookie status.'"`
Import AuthImportCmd `kong:"cmd,help='Import browser cookies.'"`
Paste AuthPasteCmd `kong:"cmd,help='Paste cookie values from the browser.'"`
Clear AuthClearCmd `kong:"cmd,help='Clear stored cookies.'"`
}

Expand All @@ -27,11 +33,19 @@ type AuthImportCmd struct {
Domain string `help:"Cookie domain suffix." default:"spotify.com"`
}

type AuthPasteCmd struct {
CookiePath string `help:"Cookie cache file path."`
Domain string `help:"Cookie domain suffix." default:"spotify.com"`
Path string `help:"Cookie path." default:"/"`
}

type AuthClearCmd struct{}

type authStatusPayload struct {
CookieCount int `json:"cookie_count"`
HasSPDC bool `json:"has_sp_dc"`
HasSPT bool `json:"has_sp_t"`
HasSPKey bool `json:"has_sp_key"`
Source string `json:"source"`
}

Expand All @@ -41,24 +55,40 @@ func (cmd *AuthStatusCmd) Run(ctx *app.Context) error {
return err
}
hasSPDC := false
hasSPT := false
hasSPKey := false
for _, cookie := range cookiesList {
if cookie.Name == "sp_dc" {
switch cookie.Name {
case "sp_dc":
hasSPDC = true
break
case "sp_t":
hasSPT = true
case "sp_key":
hasSPKey = true
}
}
payload := authStatusPayload{
CookieCount: len(cookiesList),
HasSPDC: hasSPDC,
HasSPT: hasSPT,
HasSPKey: hasSPKey,
Source: sourceLabel,
}
plain := []string{fmt.Sprintf("%d\t%t\t%s", payload.CookieCount, payload.HasSPDC, payload.Source)}
plain := []string{fmt.Sprintf("%d\t%t\t%t\t%t\t%s", payload.CookieCount, payload.HasSPDC, payload.HasSPT, payload.HasSPKey, payload.Source)}
human := []string{fmt.Sprintf("Cookies: %d (%s)", payload.CookieCount, payload.Source)}
if hasSPDC {
human = append(human, "Session cookie: sp_dc")
} else {
human = append(human, "Session cookie: missing sp_dc")
}
if hasSPT {
human = append(human, "Device cookie: sp_t")
} else {
human = append(human, "Device cookie: missing sp_t (needed for connect playback)")
}
if hasSPKey {
human = append(human, "Optional cookie: sp_key")
}
return ctx.Output.Emit(payload, plain, human)
}

Expand Down Expand Up @@ -114,16 +144,104 @@ func (cmd *AuthImportCmd) Run(ctx *app.Context) error {
return ctx.Output.Emit(payload, plain, human)
}

func (cmd *AuthPasteCmd) Run(ctx *app.Context) error {
reader := bufio.NewReader(os.Stdin)
stdinIsTTY := isatty.IsTerminal(os.Stdin.Fd())
if ctx.Settings.NoInput && stdinIsTTY {
return errors.New("--no-input set; pipe cookie values via stdin")
}
interactive := stdinIsTTY && !ctx.Settings.NoInput

spdc, err := readCookieValue(reader, ctx.Output, "sp_dc", true, interactive)
if err != nil {
return err
}
spkey, err := readCookieValue(reader, ctx.Output, "sp_key", false, interactive)
if err != nil {
return err
}
spt, err := readCookieValue(reader, ctx.Output, "sp_t", false, interactive)
if err != nil {
return err
}

if spdc == "" {
return errors.New("sp_dc required")
}

domain := normalizeCookieDomain(cmd.Domain)
path := strings.TrimSpace(cmd.Path)
if path == "" {
path = "/"
}

cookiesList := []*http.Cookie{{
Name: "sp_dc",
Value: spdc,
Domain: domain,
Path: path,
Secure: true,
HttpOnly: true,
}}
if spkey != "" {
cookiesList = append(cookiesList, &http.Cookie{
Name: "sp_key",
Value: spkey,
Domain: domain,
Path: path,
Secure: true,
HttpOnly: true,
})
}
if spt != "" {
cookiesList = append(cookiesList, &http.Cookie{
Name: "sp_t",
Value: spt,
Domain: domain,
Path: path,
Secure: true,
HttpOnly: true,
})
} else if strings.EqualFold(strings.TrimSpace(ctx.Profile.Engine), "") || strings.EqualFold(strings.TrimSpace(ctx.Profile.Engine), "connect") || strings.EqualFold(strings.TrimSpace(ctx.Profile.Engine), "auto") {
_, _ = fmt.Fprintln(ctx.Output.Err, "warning: missing sp_t; playback may fail (grab sp_t from DevTools)")
}

pathOut := cmd.CookiePath
if pathOut == "" {
pathOut = ctx.ResolveCookiePath()
}
if err := cookies.Write(pathOut, cookiesList); err != nil {
return err
}
profileCfg := ctx.Profile
profileCfg.CookiePath = pathOut
if err := ctx.SaveProfile(profileCfg); err != nil {
return err
}
human := []string{fmt.Sprintf("Saved %d cookies to %s", len(cookiesList), pathOut)}
plain := []string{fmt.Sprintf("%d\t%s", len(cookiesList), pathOut)}
payload := map[string]any{
"cookie_count": len(cookiesList),
"path": pathOut,
}
return ctx.Output.Emit(payload, plain, human)
}

func (cmd *AuthClearCmd) Run(ctx *app.Context) error {
path := ctx.ResolveCookiePath()
if path == "" {
return fmt.Errorf("no cookie path configured")
}
if err := trashFile(path); err != nil {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
profileCfg := ctx.Profile
profileCfg.CookiePath = ""
if err := ctx.SaveProfile(profileCfg); err != nil {
return err
}
plain := []string{"ok"}
human := []string{fmt.Sprintf("Moved %s to Trash", path)}
human := []string{fmt.Sprintf("Removed %s", path)}
return ctx.Output.Emit(map[string]string{"status": "ok"}, plain, human)
}

Expand All @@ -150,13 +268,61 @@ func readCookies(ctx *app.Context) ([]*http.Cookie, string, error) {
return browserCookies, "browser", nil
}

func trashFile(path string) error {
if _, err := exec.LookPath("trash"); err != nil {
return fmt.Errorf("trash command not found; delete %s manually", path)
func readCookieValue(reader *bufio.Reader, out *output.Writer, name string, required bool, showPrompt bool) (string, error) {
if reader == nil {
reader = bufio.NewReader(os.Stdin)
}
promptText := fmt.Sprintf("Paste %s value: ", name)
if out != nil && showPrompt {
_, _ = fmt.Fprint(out.Err, promptText)
}
cmd := exec.Command("trash", path)
if err := cmd.Run(); err != nil {
return fmt.Errorf("trash failed: %w", err)
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
value := normalizeCookieValue(line, name)
if value == "" && required {
return "", fmt.Errorf("%s required", name)
}
return value, nil
}

func normalizeCookieDomain(domain string) string {
trimmed := strings.TrimSpace(domain)
if trimmed == "" {
trimmed = "spotify.com"
}
if strings.Contains(trimmed, "://") {
if parsed, err := url.Parse(trimmed); err == nil && parsed.Hostname() != "" {
trimmed = parsed.Hostname()
}
}
if !strings.HasPrefix(trimmed, ".") {
trimmed = "." + trimmed
}
return trimmed
}

func normalizeCookieValue(value string, name string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
trimmed = strings.Trim(trimmed, "\"'")
for _, part := range strings.Split(trimmed, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
key, val, found := strings.Cut(part, "=")
if !found {
continue
}
if strings.EqualFold(strings.TrimSpace(key), name) {
return strings.Trim(strings.TrimSpace(val), "\"'")
}
}
return nil
prefix := name + "="
trimmed = strings.TrimPrefix(trimmed, prefix)
return strings.TrimSpace(trimmed)
}
Loading