Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
36 changes: 35 additions & 1 deletion cmd/setup.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Package cmd implements interactive subcommands invoked from the CLI.
// setup.go contains the provider onboarding wizard reachable via
// `cliamp setup`. It walks the user through configuring each remote
// provider (Navidrome, Plex, Jellyfin, Spotify, NetEase, YouTube Music),
// provider (Navidrome, Plex, Jellyfin, Spotify, Qobuz, NetEase, YouTube Music),
// validates the connection where possible, and writes the resulting
// TOML section to ~/.config/cliamp/config.toml.
//
Expand Down Expand Up @@ -92,6 +92,7 @@ const (
keyNetEaseBrowser = "_netease_browser"
keyYTMusicMode = "_mode"
keySpotifyMode = "_spotify_mode"
keyQobuzQuality = "_qobuz_quality"
)

func providers() []providerSpec {
Expand Down Expand Up @@ -287,6 +288,39 @@ func providers() []providerSpec {
return strings.Join(lines, "\n")
},
},
{
key: "qobuz",
name: "Qobuz",
section: "qobuz",
intro: []string{
"Lossless streaming. Requires an active Qobuz subscription.",
"",
"No API credentials needed — cliamp obtains them automatically.",
"After setup, launch cliamp, select Qobuz, and press Enter to",
"sign in via OAuth in your browser. Hi-Res tiers require a plan",
"that includes them.",
},
picker: &pickerSpec{
key: keyQobuzQuality,
label: "Stream quality",
options: []pickerOption{
{value: "6", label: "FLAC 16-bit/44.1kHz (CD) — recommended"},
{value: "7", label: "FLAC 24-bit up to 96kHz (Hi-Res)"},
{value: "27", label: "FLAC 24-bit up to 192kHz (Hi-Res)"},
{value: "5", label: "MP3 320kbps"},
},
},
body: func(v map[string]string) string {
q := v[keyQobuzQuality]
if q == "" {
q = "6"
}
return strings.Join([]string{
"enabled = true",
fmt.Sprintf("quality = %s", q),
}, "\n")
},
},
{
key: "netease",
name: "NetEase Cloud Music",
Expand Down
31 changes: 31 additions & 0 deletions cmd/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,37 @@ func TestNetEaseSetupBody(t *testing.T) {
}
}

func TestQobuzSetupBody(t *testing.T) {
spec := providerSpec{}
for _, p := range providers() {
if p.section == "qobuz" {
spec = p
break
}
}
if spec.section == "" {
t.Fatal("qobuz spec missing")
}

// Explicit quality selection.
body := spec.body(map[string]string{keyQobuzQuality: "27"})
for _, want := range []string{"enabled = true", "quality = 27"} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q: %q", want, body)
}
}

// Default quality when none picked.
if got := spec.body(map[string]string{}); !strings.Contains(got, "quality = 6") {
t.Fatalf("default quality not 6: %q", got)
}

// No live probe (auth happens interactively in the TUI).
if spec.validate != nil {
t.Fatal("qobuz spec should not define a validate probe")
}
}

func TestNetEasePickerSelectionFiltersFields(t *testing.T) {
base := newSetupModel()
neteaseIdx := -1
Expand Down
40 changes: 36 additions & 4 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"cliamp/applog"
"cliamp/cmd"
"cliamp/config"
"cliamp/external/qobuz"
"cliamp/external/spotify"
"cliamp/ipc"
"cliamp/player"
Expand All @@ -32,7 +33,7 @@ func buildApp() *cli.Command {
&cli.BoolFlag{Name: "no-mono", Usage: "disable mono output"},
&cli.BoolFlag{Name: "auto-play", Usage: "start playback immediately"},
&cli.BoolFlag{Name: "compact", Usage: "compact mode (80 columns)"},
&cli.StringFlag{Name: "provider", Usage: "default provider: radio, navidrome, plex, jellyfin, emby, spotify, soundcloud, netease, yt, youtube, ytmusic"},
&cli.StringFlag{Name: "provider", Usage: "default provider: radio, navidrome, plex, jellyfin, emby, spotify, qobuz, soundcloud, netease, yt, youtube, ytmusic"},
&cli.StringFlag{Name: "start-theme", Usage: "UI theme name"},
&cli.StringFlag{Name: "visualizer", Usage: "visualizer mode"},
&cli.StringFlag{Name: "eq-preset", Usage: "EQ preset name"},
Expand Down Expand Up @@ -69,6 +70,7 @@ func buildApp() *cli.Command {
historyCommand(),
setupCommand(),
spotifyCommand(),
qobuzCommand(),
ipcSimpleCommand("play", "resume playback"),
ipcSimpleCommand("pause", "pause playback"),
ipcSimpleCommand("toggle", "play/pause toggle"),
Expand Down Expand Up @@ -150,10 +152,10 @@ func overridesFromFlags(c *cli.Command) (config.Overrides, error) {
if c.IsSet("provider") {
v := strings.ToLower(c.String("provider"))
switch v {
case "radio", "navidrome", "spotify", "plex", "jellyfin", "emby", "soundcloud", "netease", "yt", "youtube", "ytmusic":
case "radio", "navidrome", "spotify", "qobuz", "plex", "jellyfin", "emby", "soundcloud", "netease", "yt", "youtube", "ytmusic":
ov.Provider = &v
default:
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, plex, jellyfin, emby, soundcloud, netease, yt, youtube, or ytmusic (got %q)", v)
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, qobuz, plex, jellyfin, emby, soundcloud, netease, yt, youtube, or ytmusic (got %q)", v)
}
}
if c.IsSet("start-theme") {
Expand Down Expand Up @@ -301,7 +303,7 @@ func setupCommand() *cli.Command {
Name: "setup",
Usage: "interactive wizard to configure remote providers",
Description: "Walks through configuring Navidrome, Plex, Jellyfin, Spotify,\n" +
"NetEase, and YouTube Music. Validates connections and writes\n" +
"Qobuz, NetEase, and YouTube Music. Validates connections and writes\n" +
"~/.config/cliamp/config.toml.",
Action: func(ctx context.Context, c *cli.Command) error {
return cmd.Setup()
Expand Down Expand Up @@ -339,6 +341,36 @@ func spotifyCommand() *cli.Command {
}
}

func qobuzCommand() *cli.Command {
return &cli.Command{
Name: "qobuz",
Usage: "manage Qobuz integration",
Commands: []*cli.Command{
{
Name: "reset",
Usage: "clear stored Qobuz credentials and force re-authentication",
Action: func(ctx context.Context, c *cli.Command) error {
path, err := qobuz.CredsPath()
if err != nil {
return fmt.Errorf("locate credentials: %w", err)
}
removed, err := qobuz.DeleteCreds()
if err != nil {
return fmt.Errorf("remove credentials: %w", err)
}
if !removed {
fmt.Println("No stored Qobuz credentials to remove.")
return nil
}
fmt.Printf("Removed %s\n", path)
fmt.Println("Restart cliamp and select Qobuz to sign in again.")
return nil
},
},
},
}
}

func playlistCommand() *cli.Command {
return &cli.Command{
Name: "playlist",
Expand Down
20 changes: 19 additions & 1 deletion config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ eq_preset = "Flat"
# Only used when eq_preset is "Custom" or empty
eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

# Default provider on startup: "radio", "navidrome", "spotify", "plex", "jellyfin", "emby", "soundcloud", "netease", or a YouTube provider
# Default provider on startup: "radio", "navidrome", "spotify", "qobuz", "plex", "jellyfin", "emby", "soundcloud", "netease", or a YouTube provider
# provider = "radio"

# Compact mode: cap UI width at 80 columns (default: fluid/full-width)
Expand Down Expand Up @@ -83,6 +83,24 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# rate-limit quota is shared with every librespot-based client and you
# may see occasional 429s.

# ---
# Qobuz (optional)
# Requires an active Qobuz subscription.
#
# Enable the provider, then run cliamp, select Qobuz, and press Enter to
# sign in. A browser window opens for Qobuz's OAuth login; credentials are
# cached at ~/.config/cliamp/qobuz_credentials.json and refreshed silently
# on later launches. Run "cliamp qobuz reset" to clear them.
# [qobuz]
# enabled = true
#
# Stream quality (format_id). Default 6.
# 5 = MP3 320kbps
# 6 = FLAC 16-bit/44.1kHz (CD)
# 7 = FLAC 24-bit up to 96kHz
# 27 = FLAC 24-bit up to 192kHz (Hi-Res)
# quality = 6

# ---
# Navidrome / Subsonic server (optional)
# When configured, cliamp opens the playlist browser on startup and streams
Expand Down
29 changes: 29 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@ func (s SpotifyConfig) ResolveClientID(fallbackID string) string {
return fallbackID
}

// QobuzConfig holds settings for the Qobuz provider. Requires a paid Qobuz
// subscription (Studio/Sublime). The app_id, signing secrets and OAuth private
// key are scraped automatically from the Qobuz web player, so no developer
// credentials are needed. Sign-in is an interactive OAuth browser flow.
type QobuzConfig struct {
Disabled bool // true only when user explicitly sets enabled = false
Enabled bool // true when [qobuz] section exists
Quality int // preferred stream format_id: 5 (MP3 320), 6 (FLAC CD), 7 (Hi-Res ≤96kHz), 27 (Hi-Res ≤192kHz)
}

// IsSet reports whether the Qobuz provider should be shown. Section presence
// is enough; credentials are scraped from the Qobuz web player automatically.
func (q QobuzConfig) IsSet() bool {
return !q.Disabled && q.Enabled
}

// YouTubeMusicConfig holds settings for the YouTube Music provider.
// If no client_id/client_secret are set, built-in fallback credentials are
// used automatically (same pattern as Spotify).
Expand Down Expand Up @@ -244,6 +260,7 @@ type Config struct {
InitialDirectory string // initial directory for the file browser
Navidrome NavidromeConfig // optional Navidrome/Subsonic server credentials
Spotify SpotifyConfig // optional Spotify provider (requires Premium)
Qobuz QobuzConfig // optional Qobuz provider (requires subscription)
YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider
Plex PlexConfig // optional Plex Media Server credentials
Jellyfin JellyfinConfig // optional Jellyfin server credentials
Expand Down Expand Up @@ -274,6 +291,7 @@ func defaultConfig() Config {
PaddingH: 3,
PaddingV: 1,
Spotify: SpotifyConfig{Bitrate: 320},
Qobuz: QobuzConfig{Quality: 6},
LogLevel: "info",
}
}
Expand Down Expand Up @@ -316,6 +334,8 @@ func Load() (Config, error) {
section = "ytmusic" // normalize for key parsing below
case "spotify":
cfg.Spotify.Enabled = true
case "qobuz":
cfg.Qobuz.Enabled = true
}
// Initialize plugin sub-maps for [plugins] and [plugins.*] sections.
if section == "plugins" || strings.HasPrefix(section, "plugins.") {
Expand Down Expand Up @@ -366,6 +386,15 @@ func Load() (Config, error) {
cfg.Spotify.Bitrate = v
}
}
case "qobuz":
switch key {
case "enabled":
cfg.Qobuz.Disabled = strings.ToLower(val) == "false"
case "quality":
if v, err := strconv.Atoi(val); err == nil {
cfg.Qobuz.Quality = v
}
}
case "ytmusic":
switch key {
case "enabled":
Expand Down
55 changes: 55 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,61 @@ func TestLoadSpotifyBitrate(t *testing.T) {
}
}

func TestQobuzIsSet(t *testing.T) {
tests := []struct {
name string
cfg QobuzConfig
want bool
}{
{"section present", QobuzConfig{Enabled: true}, true},
{"explicitly disabled", QobuzConfig{Enabled: true, Disabled: true}, false},
{"absent", QobuzConfig{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.IsSet(); got != tt.want {
t.Errorf("IsSet() = %v, want %v", got, tt.want)
}
})
}
}

func TestLoadQobuz(t *testing.T) {
tests := []struct {
name string
body string
wantIsSet bool
wantQuality int
}{
{"section enables, default quality", "[qobuz]\n", true, 6},
{"explicit quality", "[qobuz]\nquality = 27\n", true, 27},
{"disabled", "[qobuz]\nenabled = false\n", false, 6},
{"absent", "", false, 6},
}
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)
}
if err := os.WriteFile(path, []byte(tt.body), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got := cfg.Qobuz.IsSet(); got != tt.wantIsSet {
t.Errorf("Qobuz.IsSet() = %v, want %v", got, tt.wantIsSet)
}
if cfg.Qobuz.Quality != tt.wantQuality {
t.Errorf("Qobuz.Quality = %d, want %d", cfg.Qobuz.Quality, tt.wantQuality)
}
})
}
}

func TestPlexIsSet(t *testing.T) {
tests := []struct {
name string
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ CLI flags override config file values for the current session only. They are not

## Setup wizard

Configure remote providers (Navidrome, Plex, Jellyfin, Emby, Spotify, NetEase, YouTube Music) through a small TUI. Each provider page links to where to find the required credentials, validates the connection live, and writes the resulting `[provider]` block to `~/.config/cliamp/config.toml` without disturbing the rest of the file.
Configure remote providers (Navidrome, Plex, Jellyfin, Emby, Spotify, Qobuz, NetEase, YouTube Music) through a small TUI. Each provider page links to where to find the required credentials, validates the connection live, and writes the resulting `[provider]` block to `~/.config/cliamp/config.toml` without disturbing the rest of the file.

```sh
cliamp setup
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Configuration

For remote providers (Navidrome, Plex, Jellyfin, Emby, Spotify, NetEase, YouTube Music), the fastest path is the interactive wizard:
For remote providers (Navidrome, Plex, Jellyfin, Emby, Spotify, Qobuz, NetEase, YouTube Music), the fastest path is the interactive wizard:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

```sh
cliamp setup
Expand Down Expand Up @@ -129,7 +129,7 @@ Set which provider to start with:
provider = "radio"
```

Valid values: `radio` (default), `navidrome`, `spotify`, `plex`, `jellyfin`, `emby`, `soundcloud`, `netease`, `yt`, `youtube`, `ytmusic`.
Valid values: `radio` (default), `navidrome`, `spotify`, `plex`, `jellyfin`, `emby`, `qobuz`, `soundcloud`, `netease`, `yt`, `youtube`, `ytmusic`.

You can also override from the CLI: `cliamp --provider jellyfin`.

Expand Down
Loading