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/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/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") + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 86d167f..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{} @@ -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..d512e75 --- /dev/null +++ b/internal/spotify/applescript.go @@ -0,0 +1,300 @@ +//go:build darwin +// +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) (API, error) { + return &AppleScriptClient{ + fallback: opts.Fallback, + }, nil +} + +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 { + 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 +} + +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(ctx, script) + return err +} + +func (c *AppleScriptClient) Pause(ctx context.Context) error { + _, err := c.runScript(ctx, `tell application "Spotify" to pause`) + return err +} + +func (c *AppleScriptClient) Next(ctx context.Context) error { + _, err := c.runScript(ctx, `tell application "Spotify" to next track`) + return err +} + +func (c *AppleScriptClient) Previous(ctx context.Context) error { + _, 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(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(ctx, 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(ctx, 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(ctx, 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(ctx, 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..37e98e6 --- /dev/null +++ b/internal/spotify/applescript_stub.go @@ -0,0 +1,18 @@ +//go:build !darwin +// +build !darwin + +package spotify + +import ( + "errors" +) + +type AppleScriptClient struct{} + +type AppleScriptOptions struct { + Fallback API +} + +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") + } +} +