Skip to content
Merged
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
1 change: 1 addition & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# Add redirect URI: http://127.0.0.1:19872/login
# [spotify]
# client_id = "your-spotify-app-client-id"
# bitrate = 320

# ---
# Navidrome / Subsonic server (optional)
Expand Down
23 changes: 23 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func (n NavidromeConfig) ScrobbleEnabled() bool {
type SpotifyConfig struct {
Disabled bool // true only when user explicitly sets enabled = false
ClientID string // Spotify Developer app client ID (required)
Bitrate int // preferred Spotify stream bitrate in kbps
}

// IsSet reports whether the Spotify provider should be shown.
Expand Down Expand Up @@ -173,6 +174,7 @@ func defaultConfig() Config {
BitDepth: 16,
PaddingH: 3,
PaddingV: 1,
Spotify: SpotifyConfig{Bitrate: 320},
}
}

Expand Down Expand Up @@ -257,6 +259,10 @@ func Load() (Config, error) {
cfg.Spotify.Disabled = strings.ToLower(val) == "false"
case "client_id":
cfg.Spotify.ClientID = strings.Trim(val, `"'`)
case "bitrate":
if v, err := strconv.Atoi(val); err == nil {
cfg.Spotify.Bitrate = v
}
}
case "ytmusic":
switch key {
Expand Down Expand Up @@ -570,6 +576,7 @@ func (c *Config) clamp() {
c.BufferMs = max(min(c.BufferMs, 500), 50)
c.ResampleQuality = max(min(c.ResampleQuality, 4), 1)
c.BitDepth = clampBitDepth(c.BitDepth)
c.Spotify.Bitrate = clampSpotifyBitrate(c.Spotify.Bitrate)
c.PaddingH = max(min(c.PaddingH, 10), 0)
c.PaddingV = max(min(c.PaddingV, 5), 0)
}
Expand Down Expand Up @@ -600,6 +607,22 @@ func clampBitDepth(v int) int {
return 16
}

func clampSpotifyBitrate(v int) int {
if v <= 0 {
return 320
}
allowed := []int{96, 160, 320}
best := allowed[0]
bestDist := abs(v - best)
for _, a := range allowed[1:] {
if d := abs(v - a); d < bestDist {
best = a
bestDist = d
}
}
return best
}

func abs(x int) int {
if x < 0 {
return -x
Expand Down
68 changes: 67 additions & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package config

import "testing"
import (
"os"
"path/filepath"
"strconv"
"testing"
)

func TestDefaultConfig(t *testing.T) {
cfg := defaultConfig()
Expand Down Expand Up @@ -32,6 +37,9 @@ func TestDefaultConfig(t *testing.T) {
if cfg.PaddingV != 1 {
t.Errorf("PaddingV = %d, want 1", cfg.PaddingV)
}
if cfg.Spotify.Bitrate != 320 {
t.Errorf("Spotify.Bitrate = %d, want 320", cfg.Spotify.Bitrate)
}
if cfg.AutoPlay {
t.Error("AutoPlay should be false by default")
}
Expand Down Expand Up @@ -134,6 +142,30 @@ func TestClampBitDepth(t *testing.T) {
}
}

func TestClampSpotifyBitrate(t *testing.T) {
tests := []struct {
input int
want int
}{
{-1, 320},
{0, 320},
{96, 96},
{160, 160},
{320, 320},
{120, 96},
{128, 96},
{200, 160},
{240, 160},
{500, 320},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for _, tt := range tests {
got := clampSpotifyBitrate(tt.input)
if got != tt.want {
t.Errorf("clampSpotifyBitrate(%d) = %d, want %d", tt.input, got, tt.want)
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestClampBufferMs(t *testing.T) {
tests := []struct {
input int
Expand Down Expand Up @@ -323,6 +355,40 @@ func TestSpotifyIsSet(t *testing.T) {
}
}

func TestLoadSpotifyBitrate(t *testing.T) {
tests := []struct {
name string
bitrate int
want int
}{
{"exact supported value", 160, 160},
{"rounded to nearest supported value", 200, 160},
{"non-positive value", 0, 320},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())

path := filepath.Join(os.Getenv("HOME"), ".config", "cliamp", "config.toml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
data := []byte("[spotify]\nbitrate = " + strconv.Itoa(tt.bitrate) + "\n")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}

cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Spotify.Bitrate != tt.want {
t.Fatalf("Spotify.Bitrate = %d, want %d", cfg.Spotify.Bitrate, tt.want)
}
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestPlexIsSet(t *testing.T) {
tests := []struct {
name string
Expand Down
3 changes: 3 additions & 0 deletions docs/spotify.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ Add your client ID to `~/.config/cliamp/config.toml`:
```toml
[spotify]
client_id = "your_client_id_here"
bitrate = 320
```

`bitrate` is optional. If omitted, cliamp uses `320`. Supported values are `96`, `160`, and `320`. Non-positive values (≤ 0) are treated as `320`. Other positive values are rounded to the nearest supported bitrate.

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.

## Usage
Expand Down
11 changes: 5 additions & 6 deletions external/spotify/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,6 @@ const (
spotifyTrackPageSize = 100
)

// spotifyBitrate is the audio quality for Spotify streams (kbps).
// TODO: make bitrate configurable via config.toml
const spotifyBitrate = 320

// spotifyPlaylistItem is the raw playlist object returned by /v1/me/playlists.
type spotifyPlaylistItem struct {
ID string `json:"id"`
Expand Down Expand Up @@ -84,6 +80,7 @@ type playlistCache struct {
type SpotifyProvider struct {
session *Session
clientID string
bitrate int
userID string // Spotify user ID, fetched lazily on first Playlists() call
mu sync.Mutex
trackCache map[string]*playlistCache // playlist ID → cache entry
Expand All @@ -98,10 +95,12 @@ const playlistListCacheTTL = 5 * time.Minute

// New creates a SpotifyProvider. If session is nil, authentication is
// deferred until the user first selects the Spotify provider.
func New(session *Session, clientID string) *SpotifyProvider {
// bitrate sets the preferred Spotify stream quality in kbps (96, 160, or 320).
func New(session *Session, clientID string, bitrate int) *SpotifyProvider {
return &SpotifyProvider{
session: session,
clientID: clientID,
bitrate: bitrate,
trackCache: make(map[string]*playlistCache),
}
}
Expand Down Expand Up @@ -491,7 +490,7 @@ func (p *SpotifyProvider) NewStreamer(uri string) (beep.StreamSeekCloser, beep.F
tryStream := func() (*spotifyStreamer, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, err := p.session.NewStream(ctx, *spotID, spotifyBitrate)
stream, err := p.session.NewStream(ctx, *spotID, p.bitrate)
if err != nil {
return nil, err
}
Expand Down
3 changes: 2 additions & 1 deletion external/spotify/stub_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ type SpotifyProvider struct{}
// New returns nil — Spotify is disabled on Windows because
// go-librespot requires CGO (FLAC, Vorbis, ALSA) which cannot
// cross-compile. Callers must nil-check the return value.
func New(_ *Session, _ string) *SpotifyProvider { return nil }
// bitrate is ignored on this platform.
func New(_ *Session, _ string, _ int) *SpotifyProvider { return nil }

// Close is a no-op.
func (p *SpotifyProvider) Close() {}
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func run(overrides config.Overrides, positional []string) error {

var spotifyProv *spotify.SpotifyProvider
if cfg.Spotify.IsSet() {
spotifyProv = spotify.New(nil, cfg.Spotify.ClientID)
spotifyProv = spotify.New(nil, cfg.Spotify.ClientID, cfg.Spotify.Bitrate)
providers = append(providers, model.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv})
}

Expand Down