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
6 changes: 4 additions & 2 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

# ---
# Spotify (optional)
# A Spotify Premium account and a registered app at
# developer.spotify.com/dashboard are required.
# A Spotify Premium account is required for playback.
# If you don't set a client_id, cliamp uses a built-in fallback token
# so you can skip registering a Spotify Developer app entirely.
# Set your own client_id only if you prefer to use your own app.
# Add redirect URI: http://127.0.0.1:19872/login
# [spotify]
# client_id = "your-spotify-app-client-id"
Expand Down
13 changes: 13 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ func (s SpotifyConfig) IsSet() bool {
return !s.Disabled && s.ClientID != ""
}

// ResolveClientID returns the user's configured client ID, or calls
// fallbackFn to get one from the built-in pool if none is set.
// Returns "" only when the pool is also empty.
func (s SpotifyConfig) ResolveClientID(fallbackFn func() string) string {
if s.ClientID != "" {
return s.ClientID
}
if fallbackFn != nil {
return fallbackFn()
}
return ""
}

// Config holds user preferences loaded from the config file.
type Config struct {
Volume float64 // dB, range [-30, +6]
Expand Down
20 changes: 6 additions & 14 deletions docs/spotify.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,18 @@ Cliamp can stream your [Spotify](https://www.spotify.com/) library directly thro

## Setup

### Creating your client ID
Cliamp ships with a built-in fallback client ID, so you can use Spotify without registering your own app. Just run `cliamp`, select Spotify, and sign in.

1. Go to [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) and log in
2. Click **Create app**
3. Fill in a name (e.g. "cliamp") and description (anything works)
4. Add `http://127.0.0.1:19872/login` as a **Redirect URI**
5. Check **Web API** under "Which API/SDKs are you planning to use?"
6. Click **Save**
7. Open your app's **Settings** and copy the **Client ID**

### Configuring cliamp

Add your client ID to `~/.config/cliamp/config.toml`:
To use your own Spotify Developer app instead, add your client ID to `~/.config/cliamp/config.toml`:

```toml
[spotify]
client_id = "your_client_id_here"
```

Run `cliamp`, select Spotify as a provider, and press Enter to sign in. Credentials are cached at `~/.config/cliamp/spotify_credentials.json` — subsequent launches refresh silently.
If using your own app, add `http://127.0.0.1:19872/login` as a **Redirect URI** in the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard).

Credentials are cached at `~/.config/cliamp/spotify_credentials.json` — subsequent launches refresh silently.

## Usage

Expand Down Expand Up @@ -58,5 +50,5 @@ Only playlists in your Spotify library are shown — this includes playlists you
## Requirements

- Spotify Premium account
- A registered app at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard)
- No additional system dependencies beyond cliamp itself
- Optionally, a registered app at [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) (not required — a built-in fallback is used by default)
26 changes: 26 additions & 0 deletions external/spotify/fallback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package spotify

import (
"math/rand/v2"
)

// fallbackClientIDs is a pool of Spotify Developer app client IDs that are
// used when the user has not configured their own client_id. A random entry
// is selected each session to spread rate-limit load across apps.
//
// These are public OAuth2 client IDs (no secrets) — the PKCE flow used by
// cliamp does not require a client secret, so embedding them is safe.
var fallbackClientIDs = []string{
// Add your Spotify app client IDs here, one per line.
// "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"9cff76da7237414892754bfe1c841d9f", // @lacymorrow
}

// FallbackClientID returns a random client ID from the built-in pool,
// or "" if the pool is empty.
func FallbackClientID() string {
if len(fallbackClientIDs) == 0 {
return ""
}
return fallbackClientIDs[rand.IntN(len(fallbackClientIDs))]
}
7 changes: 5 additions & 2 deletions external/spotify/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
"offset": {fmt.Sprintf("%d", offset)},
// Request only the fields we need to reduce payload size and API cost.
// Include snapshot_id for cache invalidation.
"fields": {"items(id,name,snapshot_id,items.total,tracks.total),total"},
// Feb 2026 API: use "items.total" (was "tracks.total").
"fields": {"items(id,name,snapshot_id,items.total),total"},
}

resp, err := p.webAPI(ctx, "GET", "/v1/me/playlists", query)
Expand All @@ -140,7 +141,8 @@ func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
ID string `json:"id"`
Name string `json:"name"`
SnapshotID string `json:"snapshot_id"`
Items *struct {
// Feb 2026 API: "tracks" renamed to "items" in playlist objects.
Items *struct {
Total int `json:"total"`
} `json:"items"`
} `json:"items"`
Expand Down Expand Up @@ -210,6 +212,7 @@ func (p *SpotifyProvider) Tracks(playlistID string) ([]playlist.Track, error) {
query := url.Values{
"limit": {fmt.Sprintf("%d", limit)},
"offset": {fmt.Sprintf("%d", offset)},
// Feb 2026 API: nested object is "item" (was "track").
"fields": {"items(item(id,name,artists(name),album(name,release_date),duration_ms,track_number)),total"},
}

Expand Down
21 changes: 18 additions & 3 deletions external/spotify/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
Expand Down Expand Up @@ -335,9 +336,23 @@ func newInteractiveSession(ctx context.Context, clientID string) (*Session, erro
// decoded AudioSources — audio output is routed through cliamp's Beep pipeline,
// not go-librespot's output backend.
func (s *Session) initPlayer() error {
// go-librespot uses this for media restriction checks but Premium
// accounts can play all tracks regardless.
countryCode := "US"
// Fetch user's country for media restriction checks.
countryCode := "US" // fallback
if resp, err := s.WebApi(context.Background(), "GET", "/v1/me", nil); err != nil {
fmt.Fprintf(os.Stderr, "spotify: failed to get user profile for country: %v\n", err)
} else {
defer resp.Body.Close()
var me struct {
Country string `json:"country"`
}
if data, err := io.ReadAll(resp.Body); err != nil {
fmt.Fprintf(os.Stderr, "spotify: failed to read user profile: %v\n", err)
} else if err := json.Unmarshal(data, &me); err != nil {
fmt.Fprintf(os.Stderr, "spotify: failed to parse user profile: %v\n", err)
} else if me.Country != "" {
countryCode = me.Country
}
}
p, err := librespotPlayer.NewPlayer(&librespotPlayer.Options{
Spclient: s.sess.Spclient(),
AudioKey: s.sess.AudioKey(),
Expand Down
5 changes: 3 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ func run(overrides config.Overrides, positional []string) error {
}

var spotifyProv *spotify.SpotifyProvider
if cfg.Spotify.IsSet() {
spotifyProv = spotify.New(nil, cfg.Spotify.ClientID)
spotifyClientID := cfg.Spotify.ResolveClientID(spotify.FallbackClientID)
if !cfg.Spotify.Disabled && spotifyClientID != "" {
spotifyProv = spotify.New(nil, spotifyClientID)
providers = append(providers, ui.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv})
}

Expand Down