From 9474494dd88c7c872b9727e67a42f5ab7bbfcda7 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 5 Jan 2026 12:37:23 +1300 Subject: [PATCH 1/3] feat: add AppleScript engine for direct macOS Spotify control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `applescript` engine that controls the local Spotify app directly via AppleScript instead of going through Spotify Connect cloud servers. This solves the issue where Spotify Connect commands update the cloud state but the local app doesn't receive them (device desynced/not registered). The AppleScript engine: - Handles playback control (play, pause, next, prev, seek, volume, shuffle, repeat) - Falls back to web API for search, library, and playlist operations - Only available on macOS (darwin) Usage: spogo play --engine applescript 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/app/context.go | 23 ++- internal/cli/cli.go | 2 +- internal/spotify/applescript.go | 295 +++++++++++++++++++++++++++ internal/spotify/applescript_stub.go | 17 ++ 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 internal/spotify/applescript.go create mode 100644 internal/spotify/applescript_stub.go diff --git a/internal/app/context.go b/internal/app/context.go index 55d8f25..106f4b3 100644 --- a/internal/app/context.go +++ b/internal/app/context.go @@ -175,8 +175,29 @@ func (c *Context) Spotify() (spotify.API, error) { } c.spotifyClient = client return client, nil + case "applescript": + var fallback spotify.API + if webClient, webErr := spotify.NewClient(spotify.Options{ + TokenProvider: spotify.CookieTokenProvider{ + Source: source, + }, + Market: c.Profile.Market, + Language: c.Profile.Language, + Device: c.Profile.Device, + Timeout: c.Settings.Timeout, + }); webErr == nil { + fallback = webClient + } + client, err := spotify.NewAppleScriptClient(spotify.AppleScriptOptions{ + Fallback: fallback, + }) + if err != nil { + return nil, err + } + c.spotifyClient = client + return client, nil default: - return nil, fmt.Errorf("unknown engine %q (use auto, web, or connect)", engine) + return nil, fmt.Errorf("unknown engine %q (use auto, web, connect, or applescript)", engine) } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 86d167f..2b13a4e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -50,7 +50,7 @@ type Globals struct { Market string `help:"Market country code." env:"SPOGO_MARKET"` Language string `help:"Language/locale." env:"SPOGO_LANGUAGE"` Device string `help:"Device name or id." env:"SPOGO_DEVICE"` - Engine string `help:"Engine (auto|web|connect)." env:"SPOGO_ENGINE"` + Engine string `help:"Engine (auto|web|connect|applescript)." env:"SPOGO_ENGINE"` JSON bool `help:"JSON output." env:"SPOGO_JSON"` Plain bool `help:"Plain output." env:"SPOGO_PLAIN"` NoColor bool `help:"Disable color output." env:"SPOGO_NO_COLOR"` diff --git a/internal/spotify/applescript.go b/internal/spotify/applescript.go new file mode 100644 index 0000000..007c188 --- /dev/null +++ b/internal/spotify/applescript.go @@ -0,0 +1,295 @@ +// +build darwin + +package spotify + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" +) + +type AppleScriptClient struct { + fallback API +} + +type AppleScriptOptions struct { + Fallback API +} + +func NewAppleScriptClient(opts AppleScriptOptions) (*AppleScriptClient, error) { + return &AppleScriptClient{ + fallback: opts.Fallback, + }, nil +} + +func (c *AppleScriptClient) runScript(script string) (string, error) { + cmd := exec.Command("osascript", "-e", script) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("applescript error: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +func (c *AppleScriptClient) Play(ctx context.Context, uri string) error { + var script string + if uri == "" { + script = `tell application "Spotify" to play` + } else { + script = fmt.Sprintf(`tell application "Spotify" to play track "%s"`, uri) + } + _, err := c.runScript(script) + return err +} + +func (c *AppleScriptClient) Pause(ctx context.Context) error { + _, err := c.runScript(`tell application "Spotify" to pause`) + return err +} + +func (c *AppleScriptClient) Next(ctx context.Context) error { + _, err := c.runScript(`tell application "Spotify" to next track`) + return err +} + +func (c *AppleScriptClient) Previous(ctx context.Context) error { + _, err := c.runScript(`tell application "Spotify" to previous track`) + return err +} + +func (c *AppleScriptClient) Seek(ctx context.Context, positionMS int) error { + positionSec := positionMS / 1000 + script := fmt.Sprintf(`tell application "Spotify" to set player position to %d`, positionSec) + _, err := c.runScript(script) + return err +} + +func (c *AppleScriptClient) Volume(ctx context.Context, volume int) error { + script := fmt.Sprintf(`tell application "Spotify" to set sound volume to %d`, volume) + _, err := c.runScript(script) + return err +} + +func (c *AppleScriptClient) Shuffle(ctx context.Context, enabled bool) error { + val := "false" + if enabled { + val = "true" + } + script := fmt.Sprintf(`tell application "Spotify" to set shuffling to %s`, val) + _, err := c.runScript(script) + return err +} + +func (c *AppleScriptClient) Repeat(ctx context.Context, mode string) error { + val := "false" + if mode == "track" || mode == "context" { + val = "true" + } + script := fmt.Sprintf(`tell application "Spotify" to set repeating to %s`, val) + _, err := c.runScript(script) + return err +} + +func (c *AppleScriptClient) Playback(ctx context.Context) (PlaybackStatus, error) { + script := `tell application "Spotify" + set trackName to name of current track + set trackArtist to artist of current track + set trackAlbum to album of current track + set trackID to id of current track + set trackDuration to duration of current track + set playerPos to player position + set playerState to player state as string + set vol to sound volume + set isShuffling to shuffling + set isRepeating to repeating + return trackName & "|||" & trackArtist & "|||" & trackAlbum & "|||" & trackID & "|||" & trackDuration & "|||" & playerPos & "|||" & playerState & "|||" & vol & "|||" & isShuffling & "|||" & isRepeating +end tell` + out, err := c.runScript(script) + if err != nil { + return PlaybackStatus{}, err + } + parts := strings.Split(out, "|||") + if len(parts) < 10 { + return PlaybackStatus{}, fmt.Errorf("unexpected applescript output: %s", out) + } + durationMS, _ := strconv.Atoi(parts[4]) + positionSec, _ := strconv.ParseFloat(parts[5], 64) + volume, _ := strconv.Atoi(parts[7]) + isPlaying := parts[6] == "playing" + shuffle := parts[8] == "true" + repeat := "off" + if parts[9] == "true" { + repeat = "context" + } + item := &Item{ + URI: parts[3], + Name: parts[0], + Artists: []string{parts[1]}, + Album: parts[2], + DurationMS: durationMS, + } + return PlaybackStatus{ + IsPlaying: isPlaying, + ProgressMS: int(positionSec * 1000), + Item: item, + Device: Device{ + ID: "local", + Name: "Local Spotify", + Type: "COMPUTER", + Volume: volume, + Active: true, + }, + Shuffle: shuffle, + Repeat: repeat, + }, nil +} + +func (c *AppleScriptClient) Devices(ctx context.Context) ([]Device, error) { + return []Device{ + { + ID: "local", + Name: "Local Spotify", + Type: "COMPUTER", + Active: true, + }, + }, nil +} + +func (c *AppleScriptClient) Transfer(ctx context.Context, deviceID string) error { + return ErrUnsupported +} + +func (c *AppleScriptClient) QueueAdd(ctx context.Context, uri string) error { + if c.fallback != nil { + return c.fallback.QueueAdd(ctx, uri) + } + return ErrUnsupported +} + +func (c *AppleScriptClient) Queue(ctx context.Context) (Queue, error) { + if c.fallback != nil { + return c.fallback.Queue(ctx) + } + return Queue{}, ErrUnsupported +} + +func (c *AppleScriptClient) Search(ctx context.Context, kind, query string, limit, offset int) (SearchResult, error) { + if c.fallback != nil { + return c.fallback.Search(ctx, kind, query, limit, offset) + } + return SearchResult{}, ErrUnsupported +} + +func (c *AppleScriptClient) GetTrack(ctx context.Context, id string) (Item, error) { + if c.fallback != nil { + return c.fallback.GetTrack(ctx, id) + } + return Item{}, ErrUnsupported +} + +func (c *AppleScriptClient) GetAlbum(ctx context.Context, id string) (Item, error) { + if c.fallback != nil { + return c.fallback.GetAlbum(ctx, id) + } + return Item{}, ErrUnsupported +} + +func (c *AppleScriptClient) GetArtist(ctx context.Context, id string) (Item, error) { + if c.fallback != nil { + return c.fallback.GetArtist(ctx, id) + } + return Item{}, ErrUnsupported +} + +func (c *AppleScriptClient) GetPlaylist(ctx context.Context, id string) (Item, error) { + if c.fallback != nil { + return c.fallback.GetPlaylist(ctx, id) + } + return Item{}, ErrUnsupported +} + +func (c *AppleScriptClient) GetShow(ctx context.Context, id string) (Item, error) { + if c.fallback != nil { + return c.fallback.GetShow(ctx, id) + } + return Item{}, ErrUnsupported +} + +func (c *AppleScriptClient) GetEpisode(ctx context.Context, id string) (Item, error) { + if c.fallback != nil { + return c.fallback.GetEpisode(ctx, id) + } + return Item{}, ErrUnsupported +} + +func (c *AppleScriptClient) LibraryTracks(ctx context.Context, limit, offset int) ([]Item, int, error) { + if c.fallback != nil { + return c.fallback.LibraryTracks(ctx, limit, offset) + } + return nil, 0, ErrUnsupported +} + +func (c *AppleScriptClient) LibraryAlbums(ctx context.Context, limit, offset int) ([]Item, int, error) { + if c.fallback != nil { + return c.fallback.LibraryAlbums(ctx, limit, offset) + } + return nil, 0, ErrUnsupported +} + +func (c *AppleScriptClient) LibraryModify(ctx context.Context, path string, ids []string, method string) error { + if c.fallback != nil { + return c.fallback.LibraryModify(ctx, path, ids, method) + } + return ErrUnsupported +} + +func (c *AppleScriptClient) FollowArtists(ctx context.Context, ids []string, method string) error { + if c.fallback != nil { + return c.fallback.FollowArtists(ctx, ids, method) + } + return ErrUnsupported +} + +func (c *AppleScriptClient) FollowedArtists(ctx context.Context, limit int, after string) ([]Item, int, string, error) { + if c.fallback != nil { + return c.fallback.FollowedArtists(ctx, limit, after) + } + return nil, 0, "", ErrUnsupported +} + +func (c *AppleScriptClient) Playlists(ctx context.Context, limit, offset int) ([]Item, int, error) { + if c.fallback != nil { + return c.fallback.Playlists(ctx, limit, offset) + } + return nil, 0, ErrUnsupported +} + +func (c *AppleScriptClient) PlaylistTracks(ctx context.Context, id string, limit, offset int) ([]Item, int, error) { + if c.fallback != nil { + return c.fallback.PlaylistTracks(ctx, id, limit, offset) + } + return nil, 0, ErrUnsupported +} + +func (c *AppleScriptClient) CreatePlaylist(ctx context.Context, name string, public, collaborative bool) (Item, error) { + if c.fallback != nil { + return c.fallback.CreatePlaylist(ctx, name, public, collaborative) + } + return Item{}, ErrUnsupported +} + +func (c *AppleScriptClient) AddTracks(ctx context.Context, playlistID string, uris []string) error { + if c.fallback != nil { + return c.fallback.AddTracks(ctx, playlistID, uris) + } + return ErrUnsupported +} + +func (c *AppleScriptClient) RemoveTracks(ctx context.Context, playlistID string, uris []string) error { + if c.fallback != nil { + return c.fallback.RemoveTracks(ctx, playlistID, uris) + } + return ErrUnsupported +} diff --git a/internal/spotify/applescript_stub.go b/internal/spotify/applescript_stub.go new file mode 100644 index 0000000..d18c0db --- /dev/null +++ b/internal/spotify/applescript_stub.go @@ -0,0 +1,17 @@ +// +build !darwin + +package spotify + +import ( + "errors" +) + +type AppleScriptClient struct{} + +type AppleScriptOptions struct { + Fallback API +} + +func NewAppleScriptClient(opts AppleScriptOptions) (*AppleScriptClient, error) { + return nil, errors.New("applescript engine is only available on macOS") +} From 42e9fa8c6bc0fe3ec674b32b486ec7441676e4ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 09:09:42 +0100 Subject: [PATCH 2/3] fix: make applescript engine typecheck cross-platform --- CHANGELOG.md | 1 + README.md | 2 +- docs/spec.md | 4 +-- internal/cli/cli.go | 2 +- internal/spotify/applescript.go | 33 +++++++++++++---------- internal/spotify/applescript_stub.go | 3 ++- internal/spotify/applescript_stub_test.go | 18 +++++++++++++ 7 files changed, 44 insertions(+), 19 deletions(-) create mode 100644 internal/spotify/applescript_stub_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ed223..617b3ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.1.1 - Unreleased +- Add `applescript` engine for direct Spotify.app control on macOS (thanks @adam91holt) - CI: bump golangci-lint-action to support golangci-lint v2 ## 0.1.0 - 2026-01-02 diff --git a/README.md b/README.md index 7f63d9d..59152a8 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Global flags: - `--market ` market country code - `--language ` language/locale (default `en`) - `--device ` target device -- `--engine ` API engine (default `connect`) +- `--engine ` API engine (default `connect`, `applescript` is macOS-only) - `--json` / `--plain` - `--no-color` - `-q, --quiet` / `-v, --verbose` / `-d, --debug` diff --git a/docs/spec.md b/docs/spec.md index 9fe5f11..0c86e61 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1,4 +1,4 @@ -# spogo CLI spec (v0.1.0) +# spogo CLI spec (v0.1.1) One-liner: Spotify power CLI using web cookies; search + playback control. Parser: Kong. @@ -29,7 +29,7 @@ spogo [global flags] [args] - `--market ` default: account market or `US` - `--language ` default: `en` - `--device ` default: active device -- `--engine ` default: `connect` +- `--engine ` default: `connect` (`applescript` is macOS-only) - `--no-input` ## Commands diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 2b13a4e..baf6beb 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -10,7 +10,7 @@ import ( "github.com/steipete/spogo/internal/output" ) -const Version = "0.1.0" +const Version = "0.1.1" func New() *CLI { return &CLI{} diff --git a/internal/spotify/applescript.go b/internal/spotify/applescript.go index 007c188..d512e75 100644 --- a/internal/spotify/applescript.go +++ b/internal/spotify/applescript.go @@ -1,3 +1,4 @@ +//go:build darwin // +build darwin package spotify @@ -18,17 +19,21 @@ type AppleScriptOptions struct { Fallback API } -func NewAppleScriptClient(opts AppleScriptOptions) (*AppleScriptClient, error) { +func NewAppleScriptClient(opts AppleScriptOptions) (API, error) { return &AppleScriptClient{ fallback: opts.Fallback, }, nil } -func (c *AppleScriptClient) runScript(script string) (string, error) { - cmd := exec.Command("osascript", "-e", script) - out, err := cmd.Output() +func (c *AppleScriptClient) runScript(ctx context.Context, script string) (string, error) { + cmd := exec.CommandContext(ctx, "osascript", "-e", script) + out, err := cmd.CombinedOutput() if err != nil { - return "", fmt.Errorf("applescript error: %w", err) + msg := strings.TrimSpace(string(out)) + if msg == "" { + return "", fmt.Errorf("applescript error: %w", err) + } + return "", fmt.Errorf("applescript error: %w (%s)", err, msg) } return strings.TrimSpace(string(out)), nil } @@ -40,35 +45,35 @@ func (c *AppleScriptClient) Play(ctx context.Context, uri string) error { } else { script = fmt.Sprintf(`tell application "Spotify" to play track "%s"`, uri) } - _, err := c.runScript(script) + _, err := c.runScript(ctx, script) return err } func (c *AppleScriptClient) Pause(ctx context.Context) error { - _, err := c.runScript(`tell application "Spotify" to pause`) + _, err := c.runScript(ctx, `tell application "Spotify" to pause`) return err } func (c *AppleScriptClient) Next(ctx context.Context) error { - _, err := c.runScript(`tell application "Spotify" to next track`) + _, err := c.runScript(ctx, `tell application "Spotify" to next track`) return err } func (c *AppleScriptClient) Previous(ctx context.Context) error { - _, err := c.runScript(`tell application "Spotify" to previous track`) + _, err := c.runScript(ctx, `tell application "Spotify" to previous track`) return err } func (c *AppleScriptClient) Seek(ctx context.Context, positionMS int) error { positionSec := positionMS / 1000 script := fmt.Sprintf(`tell application "Spotify" to set player position to %d`, positionSec) - _, err := c.runScript(script) + _, err := c.runScript(ctx, script) return err } func (c *AppleScriptClient) Volume(ctx context.Context, volume int) error { script := fmt.Sprintf(`tell application "Spotify" to set sound volume to %d`, volume) - _, err := c.runScript(script) + _, err := c.runScript(ctx, script) return err } @@ -78,7 +83,7 @@ func (c *AppleScriptClient) Shuffle(ctx context.Context, enabled bool) error { val = "true" } script := fmt.Sprintf(`tell application "Spotify" to set shuffling to %s`, val) - _, err := c.runScript(script) + _, err := c.runScript(ctx, script) return err } @@ -88,7 +93,7 @@ func (c *AppleScriptClient) Repeat(ctx context.Context, mode string) error { val = "true" } script := fmt.Sprintf(`tell application "Spotify" to set repeating to %s`, val) - _, err := c.runScript(script) + _, err := c.runScript(ctx, script) return err } @@ -106,7 +111,7 @@ func (c *AppleScriptClient) Playback(ctx context.Context) (PlaybackStatus, error set isRepeating to repeating return trackName & "|||" & trackArtist & "|||" & trackAlbum & "|||" & trackID & "|||" & trackDuration & "|||" & playerPos & "|||" & playerState & "|||" & vol & "|||" & isShuffling & "|||" & isRepeating end tell` - out, err := c.runScript(script) + out, err := c.runScript(ctx, script) if err != nil { return PlaybackStatus{}, err } diff --git a/internal/spotify/applescript_stub.go b/internal/spotify/applescript_stub.go index d18c0db..37e98e6 100644 --- a/internal/spotify/applescript_stub.go +++ b/internal/spotify/applescript_stub.go @@ -1,3 +1,4 @@ +//go:build !darwin // +build !darwin package spotify @@ -12,6 +13,6 @@ type AppleScriptOptions struct { Fallback API } -func NewAppleScriptClient(opts AppleScriptOptions) (*AppleScriptClient, error) { +func NewAppleScriptClient(opts AppleScriptOptions) (API, error) { return nil, errors.New("applescript engine is only available on macOS") } diff --git a/internal/spotify/applescript_stub_test.go b/internal/spotify/applescript_stub_test.go new file mode 100644 index 0000000..20a87af --- /dev/null +++ b/internal/spotify/applescript_stub_test.go @@ -0,0 +1,18 @@ +//go:build !darwin + +package spotify + +import "testing" + +func TestNewAppleScriptClient_NonDarwin(t *testing.T) { + t.Parallel() + + client, err := NewAppleScriptClient(AppleScriptOptions{}) + if err == nil { + t.Fatal("expected error") + } + if client != nil { + t.Fatal("expected nil client") + } +} + From da91080af3cbb6b174cab3968ba65ae1d1702408 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 09:13:51 +0100 Subject: [PATCH 3/3] test: cover applescript engine on non-macOS --- internal/app/context_applescript_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 internal/app/context_applescript_test.go diff --git a/internal/app/context_applescript_test.go b/internal/app/context_applescript_test.go new file mode 100644 index 0000000..4f6d0d5 --- /dev/null +++ b/internal/app/context_applescript_test.go @@ -0,0 +1,22 @@ +//go:build !darwin + +package app + +import ( + "strings" + "testing" + + "github.com/steipete/spogo/internal/config" +) + +func TestSpotifyAppleScriptEngine_NonDarwin(t *testing.T) { + t.Parallel() + + ctx := &Context{Profile: config.Profile{CookiePath: "/tmp/cookies.json", Engine: "applescript"}} + if _, err := ctx.Spotify(); err == nil || !strings.Contains(err.Error(), "only available on macOS") { + t.Fatalf("expected macOS-only error, got: %v", err) + } + if ctx.spotifyClient != nil { + t.Fatalf("expected no cached client on error") + } +}