spotcontrol

package module
v0.0.0-...-4c80b8c Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 6, 2026 License: MIT Imports: 19 Imported by: 0

README

spotcontrol

Spotcontrol is a Go library for controlling Spotify Connect devices. It implements Spotify's modern Connect protocol stack including access point (AP) authentication, Login5 token management, dealer WebSocket real-time messaging, spclient HTTP API, and Connect State device control.

This is a modernized rewrite based on the protocol details from go-librespot and the original librespot project. Spotcontrol focuses solely on remote control of other Spotify devices — it does not play music itself.

Features

  • Access Point (AP) Protocol — Diffie-Hellman key exchange, Shannon stream cipher encryption, automatic reconnection with backoff
  • Login5 Authentication — Modern token-based auth with automatic hashcash challenge solving and token renewal
  • Client Token — Automatic retrieval from clienttoken.spotify.com
  • AP Resolver — Discovers and caches accesspoint, spclient, and dealer endpoints via apresolve.spotify.com
  • Dealer WebSocket — Real-time push notifications for Connect State cluster updates, ping/pong keepalive, automatic reconnection
  • Spclient HTTP API — Connect State management (PUT /connect-state/v1/devices/...) and Spotify Web API proxying with automatic bearer token injection and retry logic
  • Mercury (Hermes) — Pub/sub messaging over the AP connection for legacy protocol support
  • Controller — High-level API for listing devices, play/pause/next/previous, volume, seek, shuffle, repeat, track loading, playback transfer, and queue management
  • Multiple Auth Methods — Stored credentials, OAuth2 PKCE interactive login, Spotify tokens, and encrypted discovery blobs
  • Session Orchestration — Single Session object wires together all components with a clean lifecycle
  • Convenience Helpersquick.Connect() one-liner, automatic device ID generation, JSON state persistence, built-in loggers

Architecture

┌─────────────────────────────────────────────────┐
│                   Session                        │
│  (orchestrates all components, manages auth)     │
├──────────┬──────────┬───────────┬───────────────┤
│    AP    │  Login5  │ Spclient  │    Dealer      │
│  (TCP)   │ (HTTPS)  │  (HTTPS)  │ (WebSocket)    │
├──────────┤          │           │               │
│ Mercury  │          │           │               │
│ (pub/sub)│          │           │               │
└──────────┴──────────┴───────────┴───────────────┘

┌─────────────────────────────────────────────────┐
│               Controller                         │
│  (high-level device control via Web API)         │
│  Uses: Spclient, Dealer                          │
└─────────────────────────────────────────────────┘

Installation

go get github.com/mcMineyC/spotcontrol
Protobuf Code Generation

The repository includes .proto source files under proto/spotify/ and pre-generated *.pb.go files. If you need to regenerate the protobuf Go code (e.g. after modifying .proto files):

  1. Install buf and the Go protobuf plugin:

    go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
    

    Note: Use protoc-gen-go v1.34.x to generate files with var rawDesc = []byte{...} literals. Newer versions (v1.36+) generate const rawDesc string with unsafe.StringData, which can cause panics with some Go toolchain versions.

  2. Generate from the proto/ directory:

    cd proto
    buf generate
    

    This produces *.pb.go files alongside their corresponding .proto sources using paths=source_relative.

Quick Start

One-liner with quick.Connect()

The simplest way to get started — handles session creation, authentication, state persistence, and controller setup in a single call:

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/mcMineyC/spotcontrol/quick"
)

func main() {
    ctx := context.Background()

    // Connect handles everything: load/save state, authenticate,
    // create session + controller, start the dealer.
    result, err := quick.Connect(ctx, quick.QuickConfig{
        StatePath:   "spotcontrol_state.json", // persists device ID, credentials, OAuth2 tokens
        DeviceName:  "MyApp",
        Interactive: true, // OAuth2 PKCE login if no stored credentials
    })
    if err != nil {
        log.Fatal(err)
    }
    defer result.Close()

    fmt.Printf("Connected as: %s\n", result.Session.Username())

    // List devices.
    devices := result.ListDevices()
    for _, d := range devices {
        fmt.Printf("Device: %s (%s) active=%v\n", d.Name, d.Type, d.IsActive)
    }

    // Play a track on the active device.
    if err := result.Play(ctx, ""); err != nil {
        log.Fatal(err)
    }
}

On the first run, quick.Connect() opens an OAuth2 PKCE flow (prints a URL for the user to visit). Credentials are automatically saved to spotcontrol_state.json, so subsequent runs authenticate silently.

Advanced: Manual Session + Controller

For full control over session configuration, use session.NewSessionFromOptions and controller.NewController directly:

package main

import (
    "context"
    "fmt"
    "log"

    spotcontrol "github.com/mcMineyC/spotcontrol"
    "github.com/mcMineyC/spotcontrol/controller"
    "github.com/mcMineyC/spotcontrol/session"
)

func main() {
    ctx := context.Background()

    sess, err := session.NewSessionFromOptions(ctx, &session.Options{
        Log:        spotcontrol.NewSimpleLogger(nil), // or NewSlogLogger, or your own Logger
        DeviceType: spotcontrol.DeviceTypeComputer,    // re-exported from protobuf for convenience
        DeviceName: "MyApp",
        // DeviceId is auto-generated if empty.
        Credentials: session.InteractiveCredentials{
            CallbackPort: 0, // random port
        },
    })
    if err != nil {
        log.Fatal(err)
    }
    defer sess.Close()

    // Save credentials for next time.
    state := sess.ExportState()
    if err := spotcontrol.SaveState("state.json", state); err != nil {
        log.Printf("warning: failed saving state: %v", err)
    }

    // Create and start the controller.
    ctrl := sess.NewController() // convenience method, or use controller.NewController(cfg)
    defer ctrl.Close()

    if err := ctrl.Start(ctx); err != nil {
        log.Fatal(err)
    }

    // List devices via the Web API.
    devices, err := ctrl.ListDevicesFromAPI(ctx)
    if err != nil {
        log.Fatal(err)
    }
    for _, d := range devices {
        fmt.Printf("Device: %s (%s) active=%v vol=%d%%\n", d.Name, d.Type, d.IsActive, d.Volume)
    }

    // Play a track.
    err = ctrl.LoadTrack(ctx, []string{"spotify:track:6rqhFgbbKwnb9MLmUQDhG6"}, nil)
    if err != nil {
        log.Fatal(err)
    }
}
Using Stored Credentials
// Load previously saved state.
state, _ := spotcontrol.LoadState("state.json")

sess, err := session.NewSessionFromOptions(ctx, &session.Options{
    DeviceType: spotcontrol.DeviceTypeComputer,
    DeviceId:   state.DeviceId,
    DeviceName: "MyApp",
    Credentials: session.StoredCredentials{
        Username: state.Username,
        Data:     state.StoredCredentials,
    },
    AppState: state, // restores OAuth2 token for Web API access
})

Convenience Helpers

The root spotcontrol package provides several helpers to reduce boilerplate:

Helper Description
GenerateDeviceId() Generates a random 40-hex-char device ID (crypto/rand)
LoadState(path) Loads AppState from JSON; returns (nil, nil) if file doesn't exist
SaveState(path, state) Saves AppState as JSON with 0600 permissions
NewSimpleLogger(w) Ready-made Logger that writes to an io.Writer (suppresses Trace)
NewSlogLogger(l) Adapts *slog.Logger to the Logger interface
DeviceTypeComputer, etc. Re-exported device type constants (no protobuf import needed)

The session.Session type also provides:

Method Description
ExportState() Builds an AppState from the session (device ID, username, credentials, OAuth2 token)
NewController() Creates a controller.Controller pre-configured from the session

Example CLI

A complete interactive CLI is included in examples/micro-controller/:

cd examples/micro-controller
go build -o micro-controller

# First run — interactive OAuth2 login (saves credentials automatically):
./micro-controller --interactive

# Subsequent runs — uses saved credentials:
./micro-controller

# With a custom device name:
./micro-controller --devicename "My Speaker"

Available commands in the CLI:

Command Description
load <uri> [uri...] Load and play track(s) by Spotify URI
play Resume playback
pause Pause playback
next / prev Skip forward / backward
volume <0-100> Set volume percentage
seek <ms> Seek to position in milliseconds
shuffle <on|off> Toggle shuffle
repeat <off|context|track> Set repeat mode
queue <uri> Add track to queue
transfer <device_id> Transfer playback to another device
devices List devices from cluster state
apidevices List devices from Web API
state Show current player state

Package Overview

Package Description
spotcontrol Root package with shared types (Logger, AppState, GetAddressFunc), convenience helpers (GenerateDeviceId, LoadState/SaveState, NewSimpleLogger/NewSlogLogger), ID utilities, platform detection, version info
quick High-level convenience constructor — quick.Connect() wires together state persistence, authentication, session, and controller in one call
session Session orchestrator — wires AP, Login5, spclient, dealer, and Mercury together; handles OAuth2 PKCE flow; provides ExportState() and NewController()
controller High-level playback control API — device listing, play/pause/skip, volume, shuffle, repeat, track loading, transfer, queue
ap Access point TCP connection — DH key exchange, Shannon encryption, packet framing, ping/pong, reconnection
apresolve Service endpoint resolver (apresolve.spotify.com) for AP, dealer, and spclient addresses
dealer Dealer WebSocket client — real-time push messages, cluster updates, request/reply protocol
dh Diffie-Hellman key exchange using Spotify's 768-bit MODP group
login5 Login5 authentication client with hashcash challenge solver and automatic token renewal
mercury Mercury (Hermes) pub/sub messaging over AP packets
spclient HTTP client for Spotify's spclient API — Connect State PUT, Web API proxy, automatic auth token injection
proto/ Protobuf definitions and generated Go code for all Spotify protocol messages

Protocol Reference

This library implements the following Spotify protocols:

  • AP (Access Point): TCP connection with DH key exchange → Shannon stream cipher → framed encrypted packets. Handles Login, APWelcome, Ping/Pong, Mercury, and other packet types.
  • Login5: HTTPS POST to login5.spotify.com/v3/login with protobuf request/response. Supports hashcash challenges for rate limiting.
  • Client Token: HTTPS POST to clienttoken.spotify.com/v1/clienttoken with platform-specific device info.
  • Dealer: WebSocket connection to dealer.spotify.com with JSON-framed messages. Receives Connect State cluster updates and control requests.
  • Spclient: HTTPS API for PUT /connect-state/v1/devices/{id} (register device, update state) and proxied Web API calls.
  • Connect State: Protobuf-based device cluster management replacing the legacy SPIRC protocol. Devices register with PutStateRequest and receive ClusterUpdate pushes via the dealer.

Testing

go test ./...

License

See LICENSE.

DISCLAIMER

Much of this code was written with the use of Claude Opus 4.6

Documentation

Index

Constants

View Source
const (
	DeviceTypeComputer    = devicespb.DeviceType_COMPUTER
	DeviceTypeTablet      = devicespb.DeviceType_TABLET
	DeviceTypeSmartphone  = devicespb.DeviceType_SMARTPHONE
	DeviceTypeSpeaker     = devicespb.DeviceType_SPEAKER
	DeviceTypeTV          = devicespb.DeviceType_TV
	DeviceTypeAVR         = devicespb.DeviceType_AVR
	DeviceTypeSTB         = devicespb.DeviceType_STB
	DeviceTypeAudioDongle = devicespb.DeviceType_AUDIO_DONGLE
	DeviceTypeGameConsole = devicespb.DeviceType_GAME_CONSOLE
	DeviceTypeCastVideo   = devicespb.DeviceType_CAST_VIDEO
	DeviceTypeCastAudio   = devicespb.DeviceType_CAST_AUDIO
	DeviceTypeAutomobile  = devicespb.DeviceType_AUTOMOBILE
	DeviceTypeSmartwatch  = devicespb.DeviceType_SMARTWATCH
	DeviceTypeChromebook  = devicespb.DeviceType_CHROMEBOOK
	DeviceTypeCarThing    = devicespb.DeviceType_CAR_THING
)

Device type constants re-exported from the protobuf package for convenience. Users can use these instead of importing the deeply nested protobuf package directly. For example:

spotcontrol.DeviceTypeComputer

is equivalent to:

devicespb.DeviceType_COMPUTER
View Source
const SpotifyVersionCode = 127700358

SpotifyVersionCode is the version code sent during AP key exchange.

Variables

View Source
var ClientId = []byte{0x65, 0xb7, 0x08, 0x07, 0x3f, 0xc0, 0x48, 0x0e, 0xa9, 0x2a, 0x07, 0x72, 0x33, 0xca, 0x87, 0xbd}

ClientId is the Spotify client ID used for authentication. This is the well-known client ID used by official Spotify clients.

View Source
var ClientIdHex = hex.EncodeToString(ClientId)

ClientIdHex is the hex-encoded string representation of the client ID.

View Source
var UriRegexp = regexp.MustCompile(`^spotify:([a-z]+):([0-9a-zA-Z]{21,22})$`)

UriRegexp matches Spotify URIs in the form spotify:type:id.

Functions

func Base62ToGid

func Base62ToGid(id string) ([]byte, error)

Base62ToGid converts a base62-encoded string to a 16-byte GID.

func Convert62

func Convert62(id string) []byte

Convert62 converts a base62-encoded Spotify ID string to raw bytes. This is a legacy helper; prefer Base62ToGid for new code.

func ConvertTo62

func ConvertTo62(raw []byte) string

ConvertTo62 converts raw bytes to a base62-encoded Spotify ID string, zero-padded to 22 characters. This is a legacy helper; prefer GidToBase62 for new code.

func GenerateDeviceId

func GenerateDeviceId() string

GenerateDeviceId generates a random 40-character hex string (20 random bytes) suitable for use as a Spotify device identifier. It uses crypto/rand for cryptographically secure random bytes.

The returned string is guaranteed to be exactly 40 lowercase hex characters, which is the format required by session.Options.DeviceId.

func GetCpuFamily

func GetCpuFamily() spotifypb.CpuFamily

GetCpuFamily returns the protobuf CpuFamily enum corresponding to the current CPU architecture.

func GetOS

func GetOS() spotifypb.Os

GetOS returns the protobuf Os enum corresponding to the current operating system.

func GetPlatform

func GetPlatform() spotifypb.Platform

GetPlatform returns the protobuf Platform enum corresponding to the current OS/arch combination.

func GetPlatformSpecificData

func GetPlatformSpecificData() *clienttokenpb.PlatformSpecificData

GetPlatformSpecificData returns the platform-specific data protobuf used when retrieving client tokens.

func GidToBase62

func GidToBase62(id []byte) string

GidToBase62 converts a raw 16-byte GID to a base62-encoded string, zero-padded to 22 characters.

func ObfuscateUsername

func ObfuscateUsername(username string) string

ObfuscateUsername returns an obfuscated version of a username for logging.

func SaveState

func SaveState(path string, state *AppState) error

SaveState writes an AppState as pretty-printed JSON to the given path with file mode 0600 (owner read/write only) to protect credentials.

func SpotifyLikeClientVersion

func SpotifyLikeClientVersion() string

SpotifyLikeClientVersion returns a version string formatted like official Spotify clients.

func SystemInfoString

func SystemInfoString() string

SystemInfoString returns a system information string for use in protobuf fields.

func UserAgent

func UserAgent() string

UserAgent returns the HTTP User-Agent string for spotcontrol requests.

func VersionNumberString

func VersionNumberString() string

VersionNumberString returns the version number as a string. If set via ldflags, it returns that value (without leading "v"). Otherwise, it returns the first 8 characters of the commit hash, or "dev".

func VersionString

func VersionString() string

VersionString returns the full version string including the project name.

Types

type AppState

type AppState struct {
	// DeviceId is the unique device identifier for this controller instance.
	DeviceId string `json:"device_id"`
	// Username is the authenticated Spotify username.
	Username string `json:"username,omitempty"`
	// StoredCredentials is the reusable auth credential data from APWelcome.
	StoredCredentials []byte `json:"stored_credentials,omitempty"`

	// OAuthAccessToken is the OAuth2 access token for the Spotify Web API.
	OAuthAccessToken string `json:"oauth_access_token,omitempty"`
	// OAuthRefreshToken is the OAuth2 refresh token used to obtain new access
	// tokens when the current one expires.
	OAuthRefreshToken string `json:"oauth_refresh_token,omitempty"`
	// OAuthTokenType is the token type (typically "Bearer").
	OAuthTokenType string `json:"oauth_token_type,omitempty"`
	// OAuthExpiry is the time at which the access token expires.
	OAuthExpiry time.Time `json:"oauth_expiry,omitempty"`
}

AppState holds persisted state across sessions.

func LoadState

func LoadState(path string) (*AppState, error)

LoadState reads an AppState from a JSON file at the given path. If the file does not exist, it returns (nil, nil) — not an error — so callers can treat a missing file as "no prior state" without extra checks.

func (*AppState) HasOAuthToken

func (s *AppState) HasOAuthToken() bool

HasOAuthToken returns true if the AppState contains a persisted OAuth2 token (at minimum an access token or a refresh token).

type DeviceType

type DeviceType = devicespb.DeviceType

DeviceType is a type alias for the protobuf DeviceType enum. Using a type alias (=) rather than a new type means these constants are fully interchangeable with devicespb.DeviceType values — existing code that uses the protobuf constants directly continues to compile without changes.

type GetAddressFunc

type GetAddressFunc func(ctx context.Context) string

GetAddressFunc is a function that returns a different address for a type of endpoint each time it is called, rotating through available addresses.

type GetLogin5TokenFunc

type GetLogin5TokenFunc func(ctx context.Context, force bool) (string, error)

GetLogin5TokenFunc is a function that returns an access token from Login5. If force is true, a new token will be obtained even if the current one hasn't expired.

type Logger

type Logger interface {
	Tracef(format string, args ...interface{})
	Debugf(format string, args ...interface{})
	Infof(format string, args ...interface{})
	Warnf(format string, args ...interface{})
	Errorf(format string, args ...interface{})

	Trace(args ...interface{})
	Debug(args ...interface{})
	Info(args ...interface{})
	Warn(args ...interface{})
	Error(args ...interface{})

	WithField(key string, value interface{}) Logger
	WithError(err error) Logger
}

Logger is the interface used throughout spotcontrol for structured logging. It is compatible with logrus.Entry and similar structured loggers.

func NewSimpleLogger

func NewSimpleLogger(w io.Writer) Logger

NewSimpleLogger returns a Logger that writes human-readable log lines to w. If w is nil, os.Stderr is used. Trace-level messages are suppressed.

This is intended as a convenient default for quick prototyping and examples. For production use, consider NewSlogLogger or a custom Logger implementation.

func NewSlogLogger

func NewSlogLogger(l *slog.Logger) Logger

NewSlogLogger returns a Logger that delegates to the provided *slog.Logger. Trace-level messages are mapped to slog.LevelDebug-4 (below Debug). Debug, Info, Warn, and Error map to their slog equivalents. WithField and WithError return new loggers with the additional attributes.

If l is nil, slog.Default() is used.

type NullLogger

type NullLogger struct{}

NullLogger is a Logger implementation that discards all output.

func (*NullLogger) Debug

func (l *NullLogger) Debug(...interface{})

func (*NullLogger) Debugf

func (l *NullLogger) Debugf(string, ...interface{})

func (*NullLogger) Error

func (l *NullLogger) Error(...interface{})

func (*NullLogger) Errorf

func (l *NullLogger) Errorf(string, ...interface{})

func (*NullLogger) Info

func (l *NullLogger) Info(...interface{})

func (*NullLogger) Infof

func (l *NullLogger) Infof(string, ...interface{})

func (*NullLogger) Trace

func (l *NullLogger) Trace(...interface{})

func (*NullLogger) Tracef

func (l *NullLogger) Tracef(string, ...interface{})

func (*NullLogger) Warn

func (l *NullLogger) Warn(...interface{})

func (*NullLogger) Warnf

func (l *NullLogger) Warnf(string, ...interface{})

func (*NullLogger) WithError

func (l *NullLogger) WithError(error) Logger

func (*NullLogger) WithField

func (l *NullLogger) WithField(string, interface{}) Logger

type SpotifyId

type SpotifyId struct {
	// contains filtered or unexported fields
}

SpotifyId represents a Spotify resource identifier, consisting of a type and a 16-byte ID.

func SpotifyIdFromBase62

func SpotifyIdFromBase62(typ SpotifyIdType, id string) (*SpotifyId, error)

SpotifyIdFromBase62 creates a SpotifyId from a type and base62-encoded string.

func SpotifyIdFromGid

func SpotifyIdFromGid(typ SpotifyIdType, id []byte) SpotifyId

SpotifyIdFromGid creates a SpotifyId from a type and raw 16-byte GID.

func SpotifyIdFromUri

func SpotifyIdFromUri(uri string) (*SpotifyId, error)

SpotifyIdFromUri parses a Spotify URI (e.g., "spotify:track:6rqhFgbbKwnb9MLmUQDhG6") and returns the corresponding SpotifyId.

func (SpotifyId) Base62

func (sid SpotifyId) Base62() string

Base62 returns the base62-encoded string of the raw identifier, zero-padded to 22 characters.

func (SpotifyId) Hex

func (sid SpotifyId) Hex() string

Hex returns the hex-encoded string of the raw identifier.

func (SpotifyId) Id

func (sid SpotifyId) Id() []byte

Id returns the raw 16-byte identifier.

func (SpotifyId) String

func (sid SpotifyId) String() string

String returns the URI representation of this SpotifyId.

func (SpotifyId) Type

func (sid SpotifyId) Type() SpotifyIdType

Type returns the type of this Spotify ID (e.g., "track", "episode").

func (SpotifyId) Uri

func (sid SpotifyId) Uri() string

Uri returns the full Spotify URI, e.g., "spotify:track:6rqhFgbbKwnb9MLmUQDhG6".

type SpotifyIdType

type SpotifyIdType string

SpotifyIdType represents the type component of a Spotify URI.

const (
	SpotifyIdTypeTrack    SpotifyIdType = "track"
	SpotifyIdTypeEpisode  SpotifyIdType = "episode"
	SpotifyIdTypeAlbum    SpotifyIdType = "album"
	SpotifyIdTypeArtist   SpotifyIdType = "artist"
	SpotifyIdTypePlaylist SpotifyIdType = "playlist"
	SpotifyIdTypeShow     SpotifyIdType = "show"
)

func InferSpotifyIdTypeFromContextUri

func InferSpotifyIdTypeFromContextUri(uri string) SpotifyIdType

InferSpotifyIdTypeFromContextUri determines whether a context URI refers to episode/show content or track content.

Directories

Path Synopsis
cmd
parsebuf command
Command parsebuf decodes mitmproxy-captured HTTP request bodies from Spotify's connect-state player command endpoint:
Command parsebuf decodes mitmproxy-captured HTTP request bodies from Spotify's connect-state player command endpoint:
examples
event-watcher command
proto

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL