diff --git a/config.toml.example b/config.toml.example index 7cff0a07..8b5a87b4 100644 --- a/config.toml.example +++ b/config.toml.example @@ -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" diff --git a/config/config.go b/config/config.go index 2985590e..31cbe19b 100644 --- a/config/config.go +++ b/config/config.go @@ -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] diff --git a/docs/spotify.md b/docs/spotify.md index dc44848f..cd721645 100644 --- a/docs/spotify.md +++ b/docs/spotify.md @@ -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 @@ -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) diff --git a/external/spotify/fallback.go b/external/spotify/fallback.go new file mode 100644 index 00000000..a852ff49 --- /dev/null +++ b/external/spotify/fallback.go @@ -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))] +} diff --git a/external/spotify/provider.go b/external/spotify/provider.go index d35330b6..51593fa5 100644 --- a/external/spotify/provider.go +++ b/external/spotify/provider.go @@ -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) @@ -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"` @@ -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"}, } diff --git a/external/spotify/session.go b/external/spotify/session.go index 8f61a07d..fbeee520 100644 --- a/external/spotify/session.go +++ b/external/spotify/session.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "net" "net/http" "net/url" @@ -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(), diff --git a/main.go b/main.go index 875a764a..3c70ced0 100644 --- a/main.go +++ b/main.go @@ -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}) }