feat(qobuz): add external provider

This commit is contained in:
Sergio Rubio
2026-06-30 19:06:35 +02:00
committed by Bjarne Øverli
27 changed files with 2228 additions and 26 deletions
+35 -1
View File
@@ -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.
//
@@ -92,6 +92,7 @@ const (
keyNetEaseBrowser = "_netease_browser"
keyYTMusicMode = "_mode"
keySpotifyMode = "_spotify_mode"
keyQobuzQuality = "_qobuz_quality"
)
func providers() []providerSpec {
@@ -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",
+31
View File
@@ -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
+36 -4
View File
@@ -14,6 +14,7 @@ import (
"cliamp/applog"
"cliamp/cmd"
"cliamp/config"
"cliamp/external/qobuz"
"cliamp/external/spotify"
"cliamp/ipc"
"cliamp/player"
@@ -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"},
@@ -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"),
@@ -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") {
@@ -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()
@@ -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",
+19 -1
View File
@@ -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)
@@ -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
+30 -1
View File
@@ -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).
@@ -229,7 +245,7 @@ type Config struct {
Speed float64 // playback speed ratio: 0.252.0 (default 1.0)
AutoPlay bool // start playback automatically on launch (radio streams, CLI tracks)
SeekStepLarge int // seconds for Shift+Left/Right seek jumps
Provider string // default provider: "radio", "navidrome", "spotify", "plex", "jellyfin", "emby", "soundcloud", "netease", "ytmusic" (default "radio")
Provider string // default provider: "radio", "navidrome", "spotify", "qobuz", "plex", "jellyfin", "emby", "soundcloud", "netease", "ytmusic" (default "radio")
Theme string // theme name, or "" for ANSI default
Visualizer string // visualizer mode name, or "" for default (Bars)
SampleRate int // output sample rate: 22050, 44100, 48000, 96000, 192000
@@ -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
@@ -274,6 +291,7 @@ func defaultConfig() Config {
PaddingH: 3,
PaddingV: 1,
Spotify: SpotifyConfig{Bitrate: 320},
Qobuz: QobuzConfig{Quality: 6},
LogLevel: "info",
}
}
@@ -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.") {
@@ -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":
+55
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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:
```sh
cliamp setup
@@ -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`.
+6 -5
View File
@@ -51,7 +51,7 @@ Press `?` or `Ctrl+K` in the player to see all keybindings.
| Key | Action |
|---|---|
| `f` | Toggle bookmark ★ on selected track (or favorite radio station in radio browser) |
| `Ctrl+F` | Search — active provider's native search (Spotify, Navidrome, Jellyfin, Emby, Plex, NetEase, Local) or YouTube fallback. Available from playlist and provider-browser views. |
| `Ctrl+F` | Search — active provider's native search (Spotify, Qobuz, Navidrome, Jellyfin, Emby, Plex, NetEase, Local) or YouTube fallback. Available from playlist and provider-browser views. |
| `u` | Load URL (stream/playlist) |
| `y` | Show lyrics |
| `Ctrl+S` | Save track to ~/Music |
@@ -65,6 +65,7 @@ Press `?` or `Ctrl+K` in the player to see all keybindings.
| `Y` | Open YouTube provider |
| `C` | Open SoundCloud provider |
| `M` | Open NetEase provider |
| `Q` | Open Qobuz provider |
## Playlist and Queue
@@ -93,7 +94,7 @@ Press `?` or `Ctrl+K` in the player to see all keybindings.
## Provider browser (`N` key)
When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Emby, Spotify, YouTube Music), the album/artist/track screens use:
When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Emby, Spotify, Qobuz, YouTube Music), the album/artist/track screens use:
| Key | Action |
|---|---|
@@ -105,7 +106,7 @@ When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Emby, Sp
| `a` | Append all visible tracks to the queue |
| `q` | Queue the highlighted track to play next |
| `s` | Cycle album sort (album list only) |
| `S` `N` `P` `J` `E` `Y` `C` `M` `L` `R` | Quick-switch to that provider without going back through the main pane |
| `S` `N` `P` `J` `E` `Y` `C` `M` `Q` `L` `R` | Quick-switch to that provider without going back through the main pane |
| `Esc` `b` | Walk back one level / close the browser |
The track screen shows a `N tracks · 47:22` subtitle and right-aligned per-track durations when the provider returns them.
@@ -122,7 +123,7 @@ The playlists pane (visible when focus is on a provider — Spotify, Navidrome,
| `/` | Filter the playlist list |
| `Ctrl+F` | Online/server search (Spotify/Navidrome/NetEase/etc.'s own search) |
| `Ctrl+R` | Refresh — re-pull the playlist list from the provider |
| `S` `N` `P` `J` `E` `Y` `C` `M` `L` `R` | Switch to that provider |
| `S` `N` `P` `J` `E` `Y` `C` `M` `Q` `L` `R` | Switch to that provider |
| `Tab` | Switch focus to EQ |
| `Esc` `b` | Back to the playlist pane |
@@ -152,7 +153,7 @@ This applies to:
- `/` file browser filter
- `Ctrl+F` when the active provider is Local (your saved playlists)
Other `Ctrl+F` providers (Spotify, Navidrome, Jellyfin, Emby, Plex, NetEase, YouTube) send your query to their own search API, so matching there follows each service's rules.
Other `Ctrl+F` providers (Spotify, Qobuz, Navidrome, Jellyfin, Emby, Plex, NetEase, YouTube) send your query to their own search API, so matching there follows each service's rules.
## General
+86
View File
@@ -0,0 +1,86 @@
# Qobuz Integration
cliamp can stream your [Qobuz](https://www.qobuz.com/) library directly through its audio pipeline. EQ, visualizer, and all effects apply. Requires an active Qobuz subscription.
Qobuz delivers lossless FLAC, so cliamp streams it through the same buffer-while-playing + ffmpeg pipeline used for other lossless providers. `ffmpeg` must be on `PATH`.
## Setup
The fastest path is the interactive wizard: run `cliamp setup`, pick **Qobuz**, choose a stream quality, and it writes the `[qobuz]` block for you.
Or configure it manually in `~/.config/cliamp/config.toml`:
```toml
[qobuz]
enabled = true
quality = 6
```
No developer credentials are needed. The `app_id`, signing secrets, and OAuth private key are scraped automatically from the Qobuz web player.
Run `cliamp`, select Qobuz as a provider, and press `Enter` to sign in. A browser window opens for Qobuz's OAuth login. Once you authorize, credentials are cached at `~/.config/cliamp/qobuz_credentials.json` and subsequent launches refresh silently.
> **Click "Back" to finish.** After you authorize, Qobuz shows a *"You are signed in, you can leave this page"* screen with a **Back** button rather than redirecting automatically. Click that **Back** button. It fires the redirect that hands the sign-in code to cliamp and completes authentication. cliamp waits (up to 5 minutes) for it.
### Quality
`quality` selects the Qobuz `format_id`. If omitted, cliamp uses `6` (FLAC CD). Supported values:
| Value | Format |
|---|---|
| `5` | MP3 320 kbps |
| `6` | FLAC 16-bit / 44.1 kHz (CD) |
| `7` | FLAC 24-bit up to 96 kHz (Hi-Res) |
| `27` | FLAC 24-bit up to 192 kHz (Hi-Res) |
Hi-Res tiers require a Qobuz plan that includes them. Any other value falls back to `6`.
## Usage
Start directly on Qobuz:
```sh
cliamp --provider qobuz
```
Once authenticated, Qobuz appears as a provider alongside the others. Press `Q` to jump straight to Qobuz, or `Esc`/`b` to open the provider browser and select it.
The provider surfaces your Qobuz library:
- **Favorite Tracks**: your liked songs.
- **Random Tracks**: a random sample of up to 500 tracks drawn from across all your playlists, with duplicates removed. Press `Ctrl+R` to reshuffle the sample.
- **Your playlists**: playlists you created or subscribed to.
- **Favorite albums**: browsable in the album view.
- **Favorite artists**: browse an artist to see their albums.
Press `Ctrl+F` while Qobuz is active to search the Qobuz catalog for tracks.
## Controls
When focused on the provider panel:
| Key | Action |
|---|---|
| `Up` `Down` / `j` `k` | Navigate |
| `Enter` | Load the selected playlist/album or play the selected track |
| `Ctrl+F` | Search Qobuz tracks |
| `Ctrl+R` | Refresh (re-resolves stream URLs) |
| `Tab` | Switch between provider and playlist focus |
| `Esc` / `b` | Open provider browser |
After loading a playlist or album you return to the standard playlist view with all the usual controls (seek, volume, EQ, shuffle, repeat, queue, search, lyrics).
## Troubleshooting
- **"OAuth failed" / browser doesn't open**: cliamp opens a localhost redirect listener on a random port. Make sure nothing is blocking outbound access to `qobuz.com` and that a default browser is configured. The flow times out after 5 minutes.
- **Sign-in seems to hang / "you can leave this page"**: after authorizing, the Qobuz OAuth page shows a confirmation screen with a **Back** button instead of redirecting automatically. Click **Back** to complete sign-in. cliamp keeps waiting (up to 5 minutes) until the redirect arrives.
- **Re-authenticate**: run `cliamp qobuz reset` to clear stored credentials, then relaunch cliamp and select Qobuz to sign in again. (Equivalent to deleting `~/.config/cliamp/qobuz_credentials.json` manually.)
- **Track is unplayable / skipped**: the track may not be streamable on your subscription tier or in your region. cliamp marks such tracks unplayable and moves on.
- **Hi-Res not delivered**: setting `quality = 27` does not upgrade a tier that lacks Hi-Res. Qobuz returns the best your plan allows.
- **Stalls after a long idle session**: signed stream URLs expire over time. Press `Ctrl+R` to refresh, which re-resolves the URLs.
## Requirements
- An active Qobuz subscription
- `ffmpeg` on `PATH` for FLAC decoding
- No developer/API registration: credentials are obtained automatically
+210
View File
@@ -0,0 +1,210 @@
package qobuz
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
// bundleBaseURL is the Qobuz web player origin that ships the JS bundle.
const bundleBaseURL = "https://play.qobuz.com"
// fallbackPrivateKey is the static OAuth code-exchange private_key documented
// for the production environment. It is used only when the value cannot be
// scraped from the bundle (the in-bundle name has changed across releases).
// Source: SofusA/qobine qobuz-api.md reverse-engineering notes.
const fallbackPrivateKey = "6lz8C03UDIC7"
// Regexes that scrape the app_id, signing secrets and OAuth private key from
// the Qobuz web player's bundle.js. Adapted from DashLt's spoofbuz (via the
// qobuz-dl-go project) and cross-checked against the SofusA/qobine
// reverse-engineered Qobuz API reference (qobuz-api.md). Qobuz has shipped
// several bundle formats over time, so the private key has multiple candidate
// patterns.
var (
reSeedTimezone = regexp.MustCompile(
`[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)`,
)
reAppID = regexp.MustCompile(
`production:{api:{appId:"(?P<app_id>\d{9})",appSecret:"\w{32}"`,
)
rePrivateKeyPatterns = []*regexp.Regexp{
regexp.MustCompile(`privateKey:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`),
regexp.MustCompile(`private_key:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`),
regexp.MustCompile(`oauthKey:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`),
regexp.MustCompile(`clientSecret:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`),
}
reBundleURL = regexp.MustCompile(
`<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>`,
)
)
// bundle holds the scraped Qobuz web player JavaScript bundle.
type bundle struct {
content string
}
// fetchBundle downloads the Qobuz login page and its bundle.js. ctx cancels the
// requests.
func fetchBundle(ctx context.Context) (*bundle, error) {
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleBaseURL+"/login", nil)
if err != nil {
return nil, fmt.Errorf("qobuz: build login request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("qobuz: get login page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("qobuz: get login page: HTTP %s", resp.Status)
}
page, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("qobuz: read login page: %w", err)
}
match := reBundleURL.FindSubmatch(page)
if match == nil {
return nil, fmt.Errorf("qobuz: bundle URL not found in login page")
}
bundlePath := string(match[1])
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleBaseURL+bundlePath, nil)
if err != nil {
return nil, fmt.Errorf("qobuz: build bundle request: %w", err)
}
resp2, err := client.Do(req2)
if err != nil {
return nil, fmt.Errorf("qobuz: get bundle.js: %w", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
return nil, fmt.Errorf("qobuz: get bundle.js: HTTP %s", resp2.Status)
}
body, err := io.ReadAll(resp2.Body)
if err != nil {
return nil, fmt.Errorf("qobuz: read bundle.js: %w", err)
}
return &bundle{content: string(body)}, nil
}
// appID extracts the Qobuz application ID from the bundle.
func (b *bundle) appID() (string, error) {
m := reAppID.FindStringSubmatch(b.content)
if m == nil {
return "", fmt.Errorf("qobuz: app_id not found in bundle")
}
return m[reAppID.SubexpIndex("app_id")], nil
}
// privateKey extracts the OAuth private key, falling back to the documented
// production value when the bundle pattern cannot be matched.
func (b *bundle) privateKey() string {
for _, re := range rePrivateKeyPatterns {
if m := re.FindStringSubmatch(b.content); m != nil {
return m[re.SubexpIndex("key")]
}
}
return fallbackPrivateKey
}
// capitalizeFirst upper-cases the first byte of s (timezone names are ASCII).
func capitalizeFirst(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// secrets extracts the API signing secrets from the bundle. The result maps a
// timezone name to its decoded secret; callers try each until one validates.
func (b *bundle) secrets() (map[string]string, error) {
seeds := make(map[string][]string)
for _, m := range reSeedTimezone.FindAllStringSubmatch(b.content, -1) {
seed := m[reSeedTimezone.SubexpIndex("seed")]
tz := m[reSeedTimezone.SubexpIndex("timezone")]
seeds[tz] = append(seeds[tz], seed)
}
if len(seeds) == 0 {
return nil, fmt.Errorf("qobuz: no seeds found in bundle")
}
// Replicate the Python OrderedDict + move_to_end ordering used by spoofbuz.
tzList := make([]string, 0, len(seeds))
for tz := range seeds {
tzList = append(tzList, tz)
}
if len(tzList) >= 2 {
tzList[0], tzList[1] = tzList[1], tzList[0]
}
capitalised := make([]string, len(tzList))
for i, tz := range tzList {
capitalised[i] = capitalizeFirst(tz)
}
reInfoExtras := regexp.MustCompile(
`name:"\w+/(?P<timezone>` + strings.Join(capitalised, "|") + `)",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"`,
)
for _, m := range reInfoExtras.FindAllStringSubmatch(b.content, -1) {
tz := strings.ToLower(m[reInfoExtras.SubexpIndex("timezone")])
info := m[reInfoExtras.SubexpIndex("info")]
extras := m[reInfoExtras.SubexpIndex("extras")]
seeds[tz] = append(seeds[tz], info, extras)
}
secrets := make(map[string]string, len(seeds))
for tz, parts := range seeds {
joined := strings.Join(parts, "")
if len(joined) <= 44 {
continue
}
trimmed := joined[:len(joined)-44]
// Pad to a multiple of 4 so StdEncoding accepts it (Python's b64decode
// pads automatically).
padded := trimmed + strings.Repeat("=", (4-len(trimmed)%4)%4)
decoded, err := base64.StdEncoding.DecodeString(padded)
if err != nil {
continue
}
secrets[tz] = string(decoded)
}
if len(secrets) == 0 {
return nil, fmt.Errorf("qobuz: no secrets decoded from bundle")
}
return secrets, nil
}
// scrapeCredentials fetches the bundle and returns the app_id, the list of
// candidate signing secrets, and the OAuth private key.
func scrapeCredentials(ctx context.Context) (string, []string, string, error) {
b, err := fetchBundle(ctx)
if err != nil {
return "", nil, "", err
}
appID, err := b.appID()
if err != nil {
return "", nil, "", err
}
secretMap, err := b.secrets()
if err != nil {
return "", nil, "", err
}
secrets := make([]string, 0, len(secretMap))
for _, s := range secretMap {
if s != "" {
secrets = append(secrets, s)
}
}
return appID, secrets, b.privateKey(), nil
}
+28
View File
@@ -0,0 +1,28 @@
package qobuz
import "testing"
func TestBundlePrivateKeyScraped(t *testing.T) {
b := &bundle{content: `foo privateKey: "scrapedKey123" bar`}
if got := b.privateKey(); got != "scrapedKey123" {
t.Fatalf("privateKey() = %q, want scraped value", got)
}
}
func TestBundlePrivateKeyFallback(t *testing.T) {
b := &bundle{content: `no key here at all`}
if got := b.privateKey(); got != fallbackPrivateKey {
t.Fatalf("privateKey() = %q, want fallback %q", got, fallbackPrivateKey)
}
}
func TestBundleAppID(t *testing.T) {
b := &bundle{content: `x=production:{api:{appId:"798273057",appSecret:"05a4851e74ee47fda346f50cfdfc4f09"}}`}
got, err := b.appID()
if err != nil {
t.Fatalf("appID() error = %v", err)
}
if got != "798273057" {
t.Fatalf("appID() = %q, want 798273057", got)
}
}
+466
View File
@@ -0,0 +1,466 @@
package qobuz
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
apiBaseURL = "https://www.qobuz.com/api.json/0.2/"
apiUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
defaultQuality = 6
)
// validQuality reports whether quality is one of the Qobuz format_id values
// cliamp accepts.
//
// 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)
func validQuality(quality int) bool {
switch quality {
case 5, 6, 7, 27:
return true
default:
return false
}
}
// maxResponseBody limits JSON API responses to 20 MB.
const maxResponseBody = 20 << 20
// client is a Qobuz API client. It is safe for concurrent use once
// authenticated (its fields are not mutated after login).
type client struct {
appID string
secrets []string // candidate signing secrets to validate
secret string // validated signing secret (set by validateSecret)
uat string // user_auth_token
userID string
label string // subscription tier short label
http *http.Client
}
func newClient(appID string, secrets []string) *client {
return &client{
appID: appID,
secrets: secrets,
http: &http.Client{Timeout: 30 * time.Second},
}
}
func md5hex(s string) string {
return fmt.Sprintf("%x", md5.Sum([]byte(s)))
}
// doRequest performs a Qobuz API request and returns the raw response body.
func (c *client) doRequest(ctx context.Context, method, endpoint string, params url.Values, body string) ([]byte, error) {
var reqBody io.Reader
if body != "" {
reqBody = strings.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, apiBaseURL+endpoint, reqBody)
if err != nil {
return nil, fmt.Errorf("qobuz: %s: build request: %w", endpoint, err)
}
req.Header.Set("User-Agent", apiUA)
req.Header.Set("X-App-Id", c.appID)
if body != "" {
// Request bodies are always form-encoded (user/login, oauth/callback).
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
} else {
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
}
if c.uat != "" {
req.Header.Set("X-User-Auth-Token", c.uat)
}
if params != nil {
req.URL.RawQuery = params.Encode()
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("qobuz: %s: request: %w", endpoint, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return nil, fmt.Errorf("qobuz: %s: read response: %w", endpoint, err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("qobuz: %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return respBody, nil
}
// doGet performs a GET and decodes the JSON response into out.
func (c *client) doGet(ctx context.Context, endpoint string, params url.Values, out any) error {
body, err := c.doRequest(ctx, http.MethodGet, endpoint, params, "")
if err != nil {
return err
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("qobuz: %s: decode: %w", endpoint, err)
}
return nil
}
// authWithToken authenticates using a user_id + user_auth_token obtained via
// OAuth and populates the client's user info.
func (c *client) authWithToken(ctx context.Context, userID, userAuthToken string) error {
params := url.Values{
"user_id": {userID},
"user_auth_token": {userAuthToken},
"app_id": {c.appID},
}
var info loginResponse
if err := c.doGet(ctx, "user/login", params, &info); err != nil {
return err
}
c.uat = userAuthToken
c.userID = userID
return c.applyUserInfo(info)
}
// loginResponse is the shape of user/login and oauth/callback responses.
type loginResponse struct {
UserAuthToken string `json:"user_auth_token"`
User struct {
ID json.Number `json:"id"`
Credential struct {
Parameters *struct {
ShortLabel string `json:"short_label"`
} `json:"parameters"`
} `json:"credential"`
} `json:"user"`
}
func (c *client) applyUserInfo(info loginResponse) error {
if info.User.Credential.Parameters == nil {
return fmt.Errorf("qobuz: account is not eligible for streaming (free accounts cannot stream)")
}
if c.uat == "" {
c.uat = info.UserAuthToken
}
if c.userID == "" && info.User.ID != "" {
c.userID = info.User.ID.String()
}
c.label = info.User.Credential.Parameters.ShortLabel
return nil
}
// loginWithOAuth completes authentication from an OAuth redirect result. Qobuz
// may return either a user_auth_token directly or a code that must be exchanged.
func (c *client) loginWithOAuth(ctx context.Context, result oauthResult, privateKey string) error {
if result.Token != "" {
c.uat = result.Token
if result.UserID != "" {
c.userID = result.UserID
}
return c.loadOAuthUserInfo(ctx, "with OAuth token")
}
if result.Code != "" {
return c.exchangeOAuthCode(ctx, result.Code, privateKey)
}
return fmt.Errorf("qobuz: OAuth redirect contained neither token nor code")
}
// exchangeOAuthCode exchanges an OAuth code for a token. Qobuz has used
// different parameter names and HTTP methods over time, so all combinations of
// (GET|POST) x ("code"|"code_autorisation") are tried.
func (c *client) exchangeOAuthCode(ctx context.Context, code, privateKey string) error {
type attempt struct {
method string
paramName string
}
attempts := []attempt{
{http.MethodGet, "code"},
{http.MethodPost, "code"},
{http.MethodGet, "code_autorisation"},
{http.MethodPost, "code_autorisation"},
}
var lastErr error
for _, a := range attempts {
params := url.Values{
a.paramName: {code},
"app_id": {c.appID},
}
if privateKey != "" {
params.Set("private_key", privateKey)
}
var body []byte
var err error
if a.method == http.MethodGet {
body, err = c.doRequest(ctx, http.MethodGet, "oauth/callback", params, "")
} else {
body, err = c.doRequest(ctx, http.MethodPost, "oauth/callback", nil, params.Encode())
}
if err != nil {
lastErr = err
continue
}
var resp struct {
Token string `json:"token"`
loginResponse
}
if err := json.Unmarshal(body, &resp); err != nil {
lastErr = err
continue
}
if resp.Token == "" {
if resp.User.Credential.Parameters != nil {
return c.applyUserInfo(resp.loginResponse)
}
lastErr = fmt.Errorf("qobuz: no token in oauth/callback response")
continue
}
c.uat = resp.Token
return c.loadOAuthUserInfo(ctx, "after OAuth")
}
return fmt.Errorf("qobuz: oauth code exchange failed: %w", lastErr)
}
func (c *client) loadOAuthUserInfo(ctx context.Context, phase string) error {
body, err := c.doRequest(ctx, http.MethodPost, "user/login", nil, "extra=partner")
if err != nil {
return fmt.Errorf("qobuz: user/login %s: %w", phase, err)
}
var info loginResponse
if err := json.Unmarshal(body, &info); err != nil {
return fmt.Errorf("qobuz: decode user/login: %w", err)
}
return c.applyUserInfo(info)
}
// validateSecret picks the first signing secret that the API accepts and stores
// it on the client. It must be called before any signed request (getFileUrl,
// favorites).
func (c *client) validateSecret(ctx context.Context) error {
if c.secret != "" {
return nil
}
for _, secret := range c.secrets {
if secret == "" {
continue
}
// 5966783 is a known public track id used purely to probe the secret.
if _, err := c.trackFileURL(ctx, "5966783", 5, secret); err == nil {
c.secret = secret
return nil
}
}
return fmt.Errorf("qobuz: no valid signing secret found")
}
// apiFileURL is the track/getFileUrl response.
//
// We use track/getFileUrl (the legacy endpoint) on purpose: it returns a plain
// "url" pointing at a complete FLAC/MP3 file that the buffered ffmpeg pipeline
// can stream directly. The current web player instead uses /file/url +
// /session/start, which returns segmented, AES-128-CTR-encrypted CMAF (qbz-1)
// requiring a full key-derivation + per-frame decryption pipeline. Per the
// SofusA/qobine reverse-engineering notes, getFileUrl "may still work but the
// web player now uses /file/url", so this is a known, monitored assumption.
type apiFileURL struct {
URL string `json:"url"`
FormatID int `json:"format_id"`
MimeType string `json:"mime_type"`
Duration int `json:"duration"`
SamplingRate float64 `json:"sampling_rate"`
BitDepth int `json:"bit_depth"`
}
// trackFileURLSig computes the request_sig for track/getFileUrl. Qobuz signs
// the concatenation of the endpoint path, the params in alphabetical order, the
// request timestamp and the app secret. The exact layout matters: a change here
// silently breaks streaming, so it's pinned by a test.
func trackFileURLSig(trackID string, formatID int, ts, secret string) string {
raw := fmt.Sprintf("trackgetFileUrlformat_id%dintentstreamtrack_id%s%s%s",
formatID, trackID, ts, secret)
return md5hex(raw)
}
// trackFileURL returns a signed streaming URL for the given track. If
// secretOverride is empty, the validated client secret is used.
func (c *client) trackFileURL(ctx context.Context, trackID string, formatID int, secretOverride string) (apiFileURL, error) {
if !validQuality(formatID) {
return apiFileURL{}, fmt.Errorf("qobuz: invalid quality %d (choose 5, 6, 7 or 27)", formatID)
}
secret := secretOverride
if secret == "" {
secret = c.secret
}
unix := strconv.FormatInt(time.Now().Unix(), 10)
params := url.Values{
"request_ts": {unix},
"request_sig": {trackFileURLSig(trackID, formatID, unix, secret)},
"track_id": {trackID},
"format_id": {strconv.Itoa(formatID)},
"intent": {"stream"},
}
var out apiFileURL
if err := c.doGet(ctx, "track/getFileUrl", params, &out); err != nil {
return apiFileURL{}, err
}
return out, nil
}
// userPlaylists returns the authenticated user's playlists.
func (c *client) userPlaylists(ctx context.Context) ([]apiPlaylist, error) {
var out struct {
Playlists apiPlaylistList `json:"playlists"`
}
params := url.Values{"limit": {"500"}, "offset": {"0"}}
if err := c.doGet(ctx, "playlist/getUserPlaylists", params, &out); err != nil {
return nil, err
}
return out.Playlists.Items, nil
}
// playlistTracks returns the tracks of a playlist, following pagination.
func (c *client) playlistTracks(ctx context.Context, playlistID string) ([]apiTrack, error) {
const pageSize = 500
var all []apiTrack
for offset := 0; ; offset += pageSize {
var out apiPlaylist
params := url.Values{
"playlist_id": {playlistID},
"extra": {"tracks"},
"limit": {strconv.Itoa(pageSize)},
"offset": {strconv.Itoa(offset)},
}
if err := c.doGet(ctx, "playlist/get", params, &out); err != nil {
return nil, err
}
if out.Tracks == nil || len(out.Tracks.Items) == 0 {
break
}
all = append(all, out.Tracks.Items...)
if offset+pageSize >= out.Tracks.Total {
break
}
}
return all, nil
}
// albumTracks returns the tracks of an album along with the album metadata.
func (c *client) albumGet(ctx context.Context, albumID string) (apiAlbum, error) {
var out apiAlbum
if err := c.doGet(ctx, "album/get", url.Values{"album_id": {albumID}}, &out); err != nil {
return apiAlbum{}, err
}
return out, nil
}
// signedFavorites builds the request_ts/request_sig params for favorites calls.
//
// Note: favorite/getUserFavorites signs only object+method+ts+secret; the
// query params (type/limit/offset) are deliberately NOT folded into the
// signature, matching the working qobuz-dl-go behavior. The qobine reference
// documents a generic "sorted params" rule, but getUserFavorites does not
// require it in practice; do not add the params to rawSig.
func (c *client) favoriteParams(favType string, offset, limit int) url.Values {
unix := strconv.FormatInt(time.Now().Unix(), 10)
rawSig := "favoritegetUserFavorites" + unix + c.secret
return url.Values{
"app_id": {c.appID},
"user_auth_token": {c.uat},
"type": {favType},
"request_ts": {unix},
"request_sig": {md5hex(rawSig)},
"limit": {strconv.Itoa(limit)},
"offset": {strconv.Itoa(offset)},
}
}
// favoriteTracks returns the user's favorite tracks.
func (c *client) favoriteTracks(ctx context.Context, offset, limit int) ([]apiTrack, error) {
var out struct {
Tracks apiTrackList `json:"tracks"`
}
if err := c.doGet(ctx, "favorite/getUserFavorites", c.favoriteParams("tracks", offset, limit), &out); err != nil {
return nil, err
}
return out.Tracks.Items, nil
}
// favoriteAlbums returns the user's favorite albums.
func (c *client) favoriteAlbums(ctx context.Context, offset, limit int) ([]apiAlbum, error) {
var out struct {
Albums apiAlbumList `json:"albums"`
}
if err := c.doGet(ctx, "favorite/getUserFavorites", c.favoriteParams("albums", offset, limit), &out); err != nil {
return nil, err
}
return out.Albums.Items, nil
}
// favoriteArtists returns the user's favorite artists.
func (c *client) favoriteArtists(ctx context.Context, offset, limit int) ([]apiArtist, error) {
var out struct {
Artists apiArtistList `json:"artists"`
}
if err := c.doGet(ctx, "favorite/getUserFavorites", c.favoriteParams("artists", offset, limit), &out); err != nil {
return nil, err
}
return out.Artists.Items, nil
}
// artistAlbums returns the albums for an artist, following pagination.
func (c *client) artistAlbums(ctx context.Context, artistID string) ([]apiAlbum, error) {
const pageSize = 500
var all []apiAlbum
for offset := 0; ; offset += pageSize {
var out struct {
apiArtist
Albums apiAlbumList `json:"albums"`
}
params := url.Values{
"app_id": {c.appID},
"artist_id": {artistID},
"extra": {"albums"},
"limit": {strconv.Itoa(pageSize)},
"offset": {strconv.Itoa(offset)},
}
if err := c.doGet(ctx, "artist/get", params, &out); err != nil {
return nil, err
}
if len(out.Albums.Items) == 0 {
break
}
all = append(all, out.Albums.Items...)
if offset+pageSize >= out.Albums.Total {
break
}
}
return all, nil
}
// searchTracks searches the Qobuz catalog for tracks.
func (c *client) searchTracks(ctx context.Context, query string, limit int) ([]apiTrack, error) {
var out struct {
Tracks apiTrackList `json:"tracks"`
}
params := url.Values{"query": {query}, "limit": {strconv.Itoa(limit)}}
if err := c.doGet(ctx, "track/search", params, &out); err != nil {
return nil, err
}
return out.Tracks.Items, nil
}
+45
View File
@@ -0,0 +1,45 @@
package qobuz
import (
"testing"
)
func TestMD5Hex(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", "d41d8cd98f00b204e9800998ecf8427e"},
{"abc", "900150983cd24fb0d6963f7d28e17f72"},
}
for _, tt := range tests {
if got := md5hex(tt.in); got != tt.want {
t.Errorf("md5hex(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
// TestTrackFileURLSig pins the request_sig layout for track/getFileUrl against
// a precomputed md5. If the raw string format in trackFileURLSig changes,
// streaming breaks and this test fails.
func TestTrackFileURLSig(t *testing.T) {
// md5("trackgetFileUrlformat_id6intentstreamtrack_id59667831700000000deadbeefsecret")
const want = "bc7a09d686b3e5c1cd32f5268eff1030"
got := trackFileURLSig("5966783", 6, "1700000000", "deadbeefsecret")
if got != want {
t.Fatalf("trackFileURLSig = %q, want %q", got, want)
}
}
func TestValidQuality(t *testing.T) {
for _, q := range []int{5, 6, 7, 27} {
if !validQuality(q) {
t.Errorf("expected quality %d to be valid", q)
}
}
for _, q := range []int{0, 1, 4, 8, 100} {
if validQuality(q) {
t.Errorf("expected quality %d to be invalid", q)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package qobuz
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"cliamp/internal/appdir"
)
// storedCreds holds persisted Qobuz credentials so the user only signs in once.
// The app_id, secrets and private key are scraped from the Qobuz web player and
// cached here alongside the OAuth user token.
type storedCreds struct {
AppID string `json:"app_id"`
Secrets []string `json:"secrets"`
Secret string `json:"secret"` // validated signing secret
PrivateKey string `json:"private_key"`
UserAuthToken string `json:"user_auth_token"`
UserID string `json:"user_id"`
Label string `json:"label"`
}
// CredsPath returns the absolute path to the stored Qobuz credentials file.
func CredsPath() (string, error) {
dir, err := appdir.Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, "qobuz_credentials.json"), nil
}
// DeleteCreds removes the stored Qobuz credentials file. Returns true if a file
// was removed, false if it did not exist.
func DeleteCreds() (bool, error) {
path, err := CredsPath()
if err != nil {
return false, err
}
if err := os.Remove(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
return true, nil
}
func loadCreds() (*storedCreds, error) {
path, err := CredsPath()
if err != nil {
return nil, fmt.Errorf("qobuz: credentials path: %w", err)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("qobuz: read credentials: %w", err)
}
var creds storedCreds
if err := json.Unmarshal(data, &creds); err != nil {
return nil, fmt.Errorf("qobuz: parse credentials: %w", err)
}
return &creds, nil
}
func saveCreds(creds *storedCreds) error {
path, err := CredsPath()
if err != nil {
return fmt.Errorf("qobuz: credentials path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("qobuz: create credentials dir: %w", err)
}
data, err := json.Marshal(creds)
if err != nil {
return fmt.Errorf("qobuz: encode credentials: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("qobuz: write credentials: %w", err)
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
// Package qobuz implements a cliamp music provider for Qobuz.
//
// It authenticates via the interactive OAuth browser flow, scrapes the
// app_id / signing secrets / OAuth private key from the Qobuz web player
// bundle.js, and resolves signed CDN stream URLs through the legacy
// track/getFileUrl endpoint. Those URLs are routed through cliamp's
// buffer-while-playing + ffmpeg pipeline (see IsStreamURL and
// RegisterBufferedURLMatcher in main.go), the same path used by the
// Navidrome, Jellyfin, Emby and Plex providers.
//
// Source material consulted for the reverse-engineered API surface:
//
// - Aeneaj/qobuz-dl-go: Go client (primary template for signing,
// bundle scraping and OAuth).
// - DashLt/spoofbuz: secret/seed extraction from bundle.js.
// - SofusA/qobine, qobuz-player-controls/examples/qobuz-api.md: a
// comprehensive reverse-engineered Qobuz API reference used to
// cross-check signing, the OAuth flow, format IDs and the
// legacy-vs-segmented (/file/url) streaming distinction.
package qobuz
+539
View File
@@ -0,0 +1,539 @@
package qobuz
import (
"context"
"fmt"
"math/rand/v2"
"slices"
"strconv"
"sync"
"time"
"cliamp/applog"
"cliamp/playlist"
"cliamp/provider"
)
// Compile-time interface checks.
var (
_ playlist.Provider = (*QobuzProvider)(nil)
_ playlist.Authenticator = (*QobuzProvider)(nil)
_ playlist.Refresher = (*QobuzProvider)(nil)
_ provider.Searcher = (*QobuzProvider)(nil)
_ provider.ArtistBrowser = (*QobuzProvider)(nil)
_ provider.AlbumBrowser = (*QobuzProvider)(nil)
_ provider.AlbumTrackLoader = (*QobuzProvider)(nil)
_ provider.Closer = (*QobuzProvider)(nil)
)
// favoriteTracksID is the synthetic playlist ID for the user's favorite tracks.
const favoriteTracksID = "favorites/tracks"
// randomTracksID is the synthetic playlist ID for a random sample of tracks
// drawn from across all of the user's playlists (deduplicated).
const randomTracksID = "playlists/random"
// resolveConcurrency bounds how many track/getFileUrl calls run in parallel
// when resolving a playlist's streaming URLs.
const resolveConcurrency = 8
// playlistFetchConcurrency bounds how many playlist/get calls run in parallel
// when gathering tracks for the Random Tracks entry.
const playlistFetchConcurrency = 8
// favoritesPageSize is the page size for favorite album/artist browsing.
const favoritesPageSize = 100
// randomTracksLimit caps the synthetic Random Tracks list. Each track costs one
// track/getFileUrl call to resolve a (short-lived) stream URL, so resolving an
// unbounded library would be slow and wasteful. When the deduplicated library
// exceeds this, a random sample is taken so it stays a fair cross-section.
// Matches the favorite tracks cap.
const randomTracksLimit = 500
// albumSortTypes is the static sort list for Qobuz album browsing. Qobuz has no
// global catalog listing, so browsing surfaces the user's favorite albums.
var albumSortTypes = []provider.SortType{
{ID: "favorites", Label: "Favorite Albums"},
}
// QobuzProvider implements playlist.Provider backed by the Qobuz API. Streaming
// URLs are resolved per track via track/getFileUrl and routed through the
// player's buffered pipeline (see stream.go).
type QobuzProvider struct {
quality int
mu sync.Mutex
client *client
authCancel context.CancelFunc
listCache []playlist.PlaylistInfo
trackCache map[string][]playlist.Track
}
// New creates a QobuzProvider. Authentication is deferred until the user first
// selects the provider. quality is the preferred Qobuz format_id.
func New(quality int) *QobuzProvider {
if !validQuality(quality) {
quality = defaultQuality
}
return &QobuzProvider{
quality: quality,
trackCache: make(map[string][]playlist.Track),
}
}
func (p *QobuzProvider) Name() string { return "Qobuz" }
// ensureClient builds an authenticated client from stored credentials only
// (no browser). Returns playlist.ErrNeedsAuth if interactive sign-in is needed.
func (p *QobuzProvider) ensureClient() (*client, error) {
p.mu.Lock()
if p.client != nil {
c := p.client
p.mu.Unlock()
return c, nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
c, err := newClientSilent(ctx)
if err != nil {
applog.Debug("qobuz: silent auth failed, prompting sign-in: %v", err)
return nil, playlist.ErrNeedsAuth
}
p.mu.Lock()
p.client = c
p.mu.Unlock()
return c, nil
}
// Authenticate runs the interactive OAuth sign-in flow (opens a browser, waits
// for the redirect). Implements playlist.Authenticator.
func (p *QobuzProvider) Authenticate() error {
p.mu.Lock()
if p.client != nil {
p.mu.Unlock()
return nil
}
if p.authCancel != nil {
p.authCancel()
p.authCancel = nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
p.mu.Lock()
p.authCancel = cancel
p.mu.Unlock()
c, err := newClientInteractive(ctx)
p.mu.Lock()
p.authCancel = nil
p.mu.Unlock()
cancel()
if err != nil {
return err
}
p.mu.Lock()
p.client = c
p.mu.Unlock()
return nil
}
// Close cancels any in-progress sign-in. Implements provider.Closer.
func (p *QobuzProvider) Close() {
p.mu.Lock()
defer p.mu.Unlock()
if p.authCancel != nil {
p.authCancel()
p.authCancel = nil
}
}
// Refresh clears cached playlists and tracks so the next call re-fetches and
// re-resolves streaming URLs (which expire). Implements playlist.Refresher.
func (p *QobuzProvider) Refresh() {
p.mu.Lock()
p.listCache = nil
p.trackCache = make(map[string][]playlist.Track)
p.mu.Unlock()
}
// Playlists returns the user's Qobuz playlists plus synthetic Favorite Tracks
// and Random Tracks entries.
func (p *QobuzProvider) Playlists() ([]playlist.PlaylistInfo, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
p.mu.Lock()
if p.listCache != nil {
cached := slices.Clone(p.listCache)
p.mu.Unlock()
return cached, nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pls, err := c.userPlaylists(ctx)
if err != nil {
return nil, err
}
lists := []playlist.PlaylistInfo{
{
ID: favoriteTracksID,
Name: "Favorite Tracks",
Section: "Library",
},
{
ID: randomTracksID,
Name: "Random Tracks",
Section: "Library",
},
}
for _, pl := range pls {
lists = append(lists, playlist.PlaylistInfo{
ID: pl.ID.String(),
Name: pl.Name,
TrackCount: pl.TracksCount,
DurationSecs: pl.Duration,
Section: "Your playlists",
})
}
p.mu.Lock()
p.listCache = lists
p.mu.Unlock()
return slices.Clone(lists), nil
}
// Tracks returns the tracks of a playlist (or the synthetic Favorite Tracks /
// Random Tracks entries), each with a resolved streaming URL.
func (p *QobuzProvider) Tracks(playlistID string) ([]playlist.Track, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
p.mu.Lock()
if cached, ok := p.trackCache[playlistID]; ok {
tracks := slices.Clone(cached)
p.mu.Unlock()
return tracks, nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
var apiTracks []apiTrack
switch playlistID {
case favoriteTracksID:
apiTracks, err = c.favoriteTracks(ctx, 0, 500)
case randomTracksID:
apiTracks, err = p.randomTracks(ctx, c)
default:
apiTracks, err = c.playlistTracks(ctx, playlistID)
}
if err != nil {
return nil, err
}
tracks := p.resolveTracks(ctx, c, apiTracks, nil)
p.mu.Lock()
p.trackCache[playlistID] = tracks
p.mu.Unlock()
return slices.Clone(tracks), nil
}
// randomTracks aggregates the tracks of every user playlist (fetched
// concurrently), drops tracks that appear in more than one playlist, and
// returns a random sample of at most randomTracksLimit. Sampling (rather than
// truncating) keeps the entry a fair cross-section of the whole library;
// refreshing the provider picks a new sample.
func (p *QobuzProvider) randomTracks(ctx context.Context, c *client) ([]apiTrack, error) {
pls, err := c.userPlaylists(ctx)
if err != nil {
return nil, fmt.Errorf("qobuz: list playlists: %w", err)
}
// Fetch each playlist's tracks in parallel, then merge in playlist order so
// dedupe (first occurrence wins) stays deterministic.
lists := make([][]apiTrack, len(pls))
errs := make([]error, len(pls))
sem := make(chan struct{}, playlistFetchConcurrency)
var wg sync.WaitGroup
for i := range pls {
wg.Add(1)
sem <- struct{}{}
go func(idx int) {
defer wg.Done()
defer func() { <-sem }()
lists[idx], errs[idx] = c.playlistTracks(ctx, pls[idx].ID.String())
}(i)
}
wg.Wait()
var all []apiTrack
for i := range pls {
if errs[i] != nil {
return nil, fmt.Errorf("qobuz: playlist %s: %w", pls[i].ID, errs[i])
}
all = append(all, lists[i]...)
}
return sampleTracks(dedupeTracksByID(all), randomTracksLimit, rand.Shuffle), nil
}
// sampleTracks shuffles a copy of in and returns up to n of the result. The
// list is always randomized because that's the whole point of the Random Tracks
// entry. When the library is larger than n, the shuffle makes it a fair
// sample of the whole library rather than its first n. The shuffle func is
// injected so tests stay deterministic; production passes rand.Shuffle, which
// is safe for concurrent use.
func sampleTracks(in []apiTrack, n int, shuffle func(n int, swap func(i, j int))) []apiTrack {
out := slices.Clone(in)
shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })
if len(out) > n {
out = out[:n]
}
return out
}
// dedupeTracksByID returns tracks with duplicate Qobuz IDs removed, keeping the
// first occurrence. Tracks with an empty ID are always kept.
func dedupeTracksByID(in []apiTrack) []apiTrack {
seen := make(map[string]bool, len(in))
out := make([]apiTrack, 0, len(in))
for _, t := range in {
id := t.ID.String()
if id != "" {
if seen[id] {
continue
}
seen[id] = true
}
out = append(out, t)
}
return out
}
// SearchTracks searches the Qobuz catalog. Implements provider.Searcher.
func (p *QobuzProvider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
if limit <= 0 {
limit = 50
}
apiTracks, err := c.searchTracks(ctx, query, limit)
if err != nil {
return nil, err
}
return p.resolveTracks(ctx, c, apiTracks, nil), nil
}
// Artists returns the user's favorite artists. Implements provider.ArtistBrowser.
func (p *QobuzProvider) Artists() ([]provider.ArtistInfo, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var artists []provider.ArtistInfo
for offset := 0; ; offset += favoritesPageSize {
page, err := c.favoriteArtists(ctx, offset, favoritesPageSize)
if err != nil {
return nil, err
}
for _, a := range page {
artists = append(artists, provider.ArtistInfo{
ID: a.ID.String(),
Name: a.Name,
AlbumCount: a.AlbumsCount,
})
}
if len(page) < favoritesPageSize {
break
}
}
return artists, nil
}
// ArtistAlbums returns the albums of an artist. Implements provider.ArtistBrowser.
func (p *QobuzProvider) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
albums, err := c.artistAlbums(ctx, artistID)
if err != nil {
return nil, err
}
out := make([]provider.AlbumInfo, 0, len(albums))
for _, a := range albums {
out = append(out, albumInfo(a))
}
return out, nil
}
// AlbumList returns the user's favorite albums (Qobuz has no global album
// catalog to browse). Implements provider.AlbumBrowser.
func (p *QobuzProvider) AlbumList(_ string, offset, size int) ([]provider.AlbumInfo, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
if size <= 0 {
size = favoritesPageSize
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
albums, err := c.favoriteAlbums(ctx, offset, size)
if err != nil {
return nil, err
}
out := make([]provider.AlbumInfo, 0, len(albums))
for _, a := range albums {
out = append(out, albumInfo(a))
}
return out, nil
}
func (p *QobuzProvider) AlbumSortTypes() []provider.SortType { return albumSortTypes }
func (p *QobuzProvider) DefaultAlbumSort() string { return "favorites" }
// AlbumTracks returns the tracks of an album. Implements provider.AlbumTrackLoader.
func (p *QobuzProvider) AlbumTracks(albumID string) ([]playlist.Track, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
album, err := c.albumGet(ctx, albumID)
if err != nil {
return nil, err
}
var tracks []apiTrack
if album.Tracks != nil {
tracks = album.Tracks.Items
}
return p.resolveTracks(ctx, c, tracks, &album), nil
}
// resolveTracks converts API tracks into playable tracks, resolving a signed
// streaming URL for each in parallel. albumFallback supplies album metadata for
// tracks that lack it (album/get nests tracks without an album field). Tracks
// that are not streamable or fail URL resolution are returned as unplayable.
func (p *QobuzProvider) resolveTracks(ctx context.Context, c *client, in []apiTrack, albumFallback *apiAlbum) []playlist.Track {
out := make([]playlist.Track, len(in))
sem := make(chan struct{}, resolveConcurrency)
var wg sync.WaitGroup
for i := range in {
wg.Add(1)
sem <- struct{}{}
go func(idx int) {
defer wg.Done()
defer func() { <-sem }()
out[idx] = p.buildTrack(ctx, c, in[idx], albumFallback)
}(i)
}
wg.Wait()
return out
}
// buildTrack maps a single API track to a playlist.Track, resolving its stream
// URL unless the track is not streamable.
func (p *QobuzProvider) buildTrack(ctx context.Context, c *client, t apiTrack, albumFallback *apiAlbum) playlist.Track {
album := t.Album
if album == nil {
album = albumFallback
}
track := playlist.Track{
Title: t.Title,
Artist: trackArtist(t, album),
TrackNumber: t.TrackNumber,
DurationSecs: t.Duration,
Stream: true,
ProviderMeta: map[string]string{provider.MetaQobuzID: t.ID.String()},
}
if album != nil {
track.Album = album.Title
track.Genre = album.Genre.Name
track.Year = parseYear(album.ReleaseDateOriginal)
}
if !t.Streamable {
track.Unplayable = true
return track
}
file, err := c.trackFileURL(ctx, t.ID.String(), p.quality, "")
if err != nil || file.URL == "" {
if err != nil {
applog.Debug("qobuz: resolve stream url for track %s: %v", t.ID.String(), err)
}
track.Unplayable = true
return track
}
registerStreamURL(file.URL)
track.Path = file.URL
return track
}
// trackArtist picks the best available artist name for a track.
func trackArtist(t apiTrack, album *apiAlbum) string {
if t.Performer.Name != "" {
return t.Performer.Name
}
if album != nil {
return album.Artist.Name
}
return ""
}
// albumInfo maps a Qobuz album to provider.AlbumInfo.
func albumInfo(a apiAlbum) provider.AlbumInfo {
return provider.AlbumInfo{
ID: a.ID,
Name: a.Title,
Artist: a.Artist.Name,
ArtistID: a.Artist.ID.String(),
Year: parseYear(a.ReleaseDateOriginal),
TrackCount: a.TracksCount,
Genre: a.Genre.Name,
}
}
// parseYear extracts the year from a Qobuz "YYYY-MM-DD" date string.
func parseYear(date string) int {
if len(date) < 4 {
return 0
}
y, err := strconv.Atoi(date[:4])
if err != nil {
return 0
}
return y
}
+160
View File
@@ -0,0 +1,160 @@
package qobuz
import (
"encoding/json"
"math/rand/v2"
"strconv"
"testing"
)
func TestParseYear(t *testing.T) {
tests := []struct {
in string
want int
}{
{"2021-05-14", 2021},
{"1999", 1999},
{"", 0},
{"abc", 0},
{"20", 0},
{"19xy-01-01", 0},
}
for _, tt := range tests {
if got := parseYear(tt.in); got != tt.want {
t.Errorf("parseYear(%q) = %d, want %d", tt.in, got, tt.want)
}
}
}
func TestTrackArtist(t *testing.T) {
withPerformer := apiTrack{Performer: apiArtist{Name: "Performer"}}
if got := trackArtist(withPerformer, nil); got != "Performer" {
t.Errorf("performer name: got %q want %q", got, "Performer")
}
album := &apiAlbum{Artist: apiArtist{Name: "AlbumArtist"}}
if got := trackArtist(apiTrack{}, album); got != "AlbumArtist" {
t.Errorf("album fallback: got %q want %q", got, "AlbumArtist")
}
if got := trackArtist(apiTrack{}, nil); got != "" {
t.Errorf("no artist: got %q want empty", got)
}
}
func TestDedupeTracksByID(t *testing.T) {
tracks := []apiTrack{
{ID: "1", Title: "first"},
{ID: "2", Title: "second"},
{ID: "1", Title: "dup of first"},
{ID: "3", Title: "third"},
{ID: "2", Title: "dup of second"},
{ID: "", Title: "no id a"},
{ID: "", Title: "no id b"},
}
got := dedupeTracksByID(tracks)
want := []struct {
id string
title string
}{
{"1", "first"}, // first occurrence wins
{"2", "second"},
{"3", "third"},
{"", "no id a"}, // empty-ID tracks are always kept
{"", "no id b"},
}
if len(got) != len(want) {
t.Fatalf("got %d tracks, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].ID.String() != w.id || got[i].Title != w.title {
t.Errorf("track %d = {%q, %q}, want {%q, %q}",
i, got[i].ID.String(), got[i].Title, w.id, w.title)
}
}
}
func TestDedupeTracksByIDEmpty(t *testing.T) {
if got := dedupeTracksByID(nil); len(got) != 0 {
t.Errorf("dedupeTracksByID(nil) = %v, want empty", got)
}
}
func TestSampleTracks(t *testing.T) {
mk := func(n int) []apiTrack {
ts := make([]apiTrack, n)
for i := range ts {
ts[i] = apiTrack{ID: json.Number(strconv.Itoa(i))}
}
return ts
}
idSet := func(ts []apiTrack) map[string]bool {
m := make(map[string]bool, len(ts))
for _, tr := range ts {
m[tr.ID.String()] = true
}
return m
}
r := rand.New(rand.NewPCG(42, 1024))
// Under the cap: every track is kept, but the list must still be shuffled.
// This is the case that used to be returned in playlist order unchanged.
in := mk(100)
got := sampleTracks(in, 500, r.Shuffle)
if len(got) != 100 {
t.Fatalf("under cap: len = %d, want 100", len(got))
}
want := idSet(in)
for _, tr := range got {
if !want[tr.ID.String()] {
t.Errorf("under cap: track %s not from input", tr.ID)
}
}
sameOrder := true
for i := range got {
if got[i].ID != in[i].ID {
sameOrder = false
break
}
}
if sameOrder {
t.Error("under cap: list was not shuffled")
}
// Over the cap: exactly n tracks, all from the input, no duplicates.
big := mk(1000)
all := idSet(big)
for range 20 {
s := sampleTracks(big, 10, r.Shuffle)
if len(s) != 10 {
t.Fatalf("over cap: len = %d, want 10", len(s))
}
seen := make(map[string]bool, len(s))
for _, tr := range s {
id := tr.ID.String()
if !all[id] {
t.Fatalf("over cap: track %q not from input", id)
}
if seen[id] {
t.Fatalf("over cap: duplicate track %q", id)
}
seen[id] = true
}
}
}
func TestNewQualityNormalization(t *testing.T) {
for _, q := range []int{5, 6, 7, 27} {
if got := New(q).quality; got != q {
t.Errorf("New(%d).quality = %d, want %d", q, got, q)
}
}
for _, q := range []int{0, 1, 99} {
if got := New(q).quality; got != defaultQuality {
t.Errorf("New(%d).quality = %d, want default %d", q, got, defaultQuality)
}
}
}
+212
View File
@@ -0,0 +1,212 @@
package qobuz
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"sync/atomic"
"time"
"cliamp/applog"
"cliamp/internal/browser"
)
// authURLObserver is invoked with the OAuth URL when interactive auth begins.
// Used by the TUI to display the URL when the launched browser does not reach
// the user (containers, headless environments).
var authURLObserver atomic.Pointer[func(string)]
// SetAuthURLObserver registers a callback invoked once with the OAuth URL at
// the start of an interactive sign-in. Pass nil to remove.
func SetAuthURLObserver(fn func(string)) {
if fn == nil {
authURLObserver.Store(nil)
return
}
authURLObserver.Store(&fn)
}
func notifyAuthURL(u string) {
applog.Info("qobuz: sign-in URL: %s", u)
if p := authURLObserver.Load(); p != nil {
(*p)(u)
}
}
// oauthResult holds the data captured from a Qobuz OAuth redirect.
type oauthResult struct {
Token string
UserID string
Code string
}
// newClientSilent builds an authenticated client from stored credentials only.
// It never opens a browser; if no usable credentials exist it returns an error.
func newClientSilent(ctx context.Context) (*client, error) {
creds, err := loadCreds()
if err != nil {
return nil, fmt.Errorf("qobuz: no stored credentials: %w", err)
}
if creds.AppID == "" || creds.UserAuthToken == "" {
return nil, fmt.Errorf("qobuz: incomplete stored credentials")
}
c := newClient(creds.AppID, creds.Secrets)
c.secret = creds.Secret
c.uat = creds.UserAuthToken
c.userID = creds.UserID
c.label = creds.Label
if err := c.authWithToken(ctx, creds.UserID, creds.UserAuthToken); err != nil {
return nil, fmt.Errorf("qobuz: stored token rejected: %w", err)
}
if c.secret == "" {
if err := c.validateSecret(ctx); err != nil {
return nil, err
}
}
// Re-persist in case the validated secret or label changed.
_ = saveCreds(credsFromClient(c, creds.PrivateKey))
return c, nil
}
// newClientInteractive scrapes fresh credentials from the Qobuz web player and
// runs the interactive OAuth browser flow, persisting the result on success.
func newClientInteractive(ctx context.Context) (*client, error) {
appID, secrets, privateKey, err := scrapeCredentials(ctx)
if err != nil {
return nil, fmt.Errorf("qobuz: scrape credentials: %w", err)
}
c := newClient(appID, secrets)
// OAuth first: the secret-validation probe (track/getFileUrl) is an
// authenticated endpoint and fails without a user_auth_token, so the
// browser sign-in must complete before validateSecret runs.
result, err := captureOAuthRedirect(ctx, appID)
if err != nil {
return nil, err
}
if err := c.loginWithOAuth(ctx, result, privateKey); err != nil {
return nil, fmt.Errorf("qobuz: OAuth login: %w", err)
}
if err := c.validateSecret(ctx); err != nil {
return nil, err
}
if err := saveCreds(credsFromClient(c, privateKey)); err != nil {
applog.UserError("qobuz: failed to save credentials: %v", err)
}
return c, nil
}
func credsFromClient(c *client, privateKey string) *storedCreds {
return &storedCreds{
AppID: c.appID,
Secrets: c.secrets,
Secret: c.secret,
PrivateKey: privateKey,
UserAuthToken: c.uat,
UserID: c.userID,
Label: c.label,
}
}
// oauthCallbackHTML is shown in the browser once the redirect is captured.
const oauthCallbackHTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>cliamp</title></head>
<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#e0e0e0">
<div style="text-align:center">
<h2>Signed in to Qobuz</h2>
<p>You can close this tab now.</p>
<script>setTimeout(function(){window.close()},1500)</script>
</div></body></html>`
// captureOAuthRedirect starts a local HTTP server on a random port, opens the
// Qobuz OAuth URL in the browser, and waits for the redirect carrying the
// token or code. Qobuz accepts any localhost redirect_url, so a random port is
// fine.
func captureOAuthRedirect(ctx context.Context, appID string) (oauthResult, error) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return oauthResult{}, fmt.Errorf("qobuz: open local port: %w", err)
}
defer lis.Close()
port := lis.Addr().(*net.TCPAddr).Port
// Qobuz validates the redirect_url host server-side and only accepts
// "localhost" (not 127.0.0.1). We still bind 127.0.0.1 below; browsers
// fall back from localhost ([::1]) to 127.0.0.1, so the capture works.
// This matches the proven SofusA/qobine reference flow.
//
// Note: after authorizing, Qobuz shows a "you are signed in, you can leave
// this page" screen with a Back button rather than redirecting back. The
// user must click Back to fire the redirect (see docs/qobuz.md). This is
// Qobuz's behavior; URL-encoding the redirect_url does not change it.
authURL := fmt.Sprintf(
"https://www.qobuz.com/signin/oauth?ext_app_id=%s&redirect_url=http://localhost:%d",
appID, port,
)
notifyAuthURL(authURL)
resultCh := make(chan oauthResult, 1)
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
applog.Debug("qobuz: oauth redirect received: %s", r.URL.RequestURI())
res := parseQueryParams(r.URL.Query())
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(oauthCallbackHTML))
if res.Token != "" || res.Code != "" {
select {
case resultCh <- res:
default:
}
} else {
applog.Debug("qobuz: oauth redirect had no token/code param")
}
}),
}
go func() { _ = srv.Serve(lis) }()
defer func() {
shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
}()
_ = browser.Open(authURL) // best-effort; user can open the URL manually
select {
case res := <-resultCh:
return res, nil
case <-ctx.Done():
return oauthResult{}, fmt.Errorf("qobuz: authentication cancelled: %w", ctx.Err())
case <-time.After(5 * time.Minute):
return oauthResult{}, errors.New("qobuz: timed out waiting for OAuth redirect")
}
}
// parseQueryParams extracts token/code/user_id from a Qobuz redirect. Qobuz has
// used several parameter names across auth flow versions.
func parseQueryParams(params url.Values) oauthResult {
var res oauthResult
if t := params.Get("user_auth_token"); t != "" {
res.Token = t
}
if t := params.Get("token"); t != "" && res.Token == "" {
res.Token = t
}
if uid := params.Get("user_id"); uid != "" {
res.UserID = uid
}
if code := params.Get("code_autorisation"); code != "" { // French spelling, Qobuz's actual param
res.Code = code
}
if code := params.Get("code"); code != "" && res.Code == "" {
res.Code = code
}
return res
}
+26
View File
@@ -0,0 +1,26 @@
package qobuz
import "sync"
// streamURLs records the signed CDN URLs that the provider has resolved via
// track/getFileUrl. The player consults IsStreamURL through a registered
// buffered-URL matcher so Qobuz FLAC streams are routed through the
// buffer-while-playing + ffmpeg pipeline (which auto-detects the codec and
// supports seeking), exactly like Navidrome's raw streams.
var streamURLs sync.Map // map[string]struct{}
// registerStreamURL marks u as a Qobuz stream URL.
func registerStreamURL(u string) {
if u == "" {
return
}
streamURLs.Store(u, struct{}{})
}
// IsStreamURL reports whether u is a Qobuz signed stream URL previously
// resolved by the provider. It is registered with the player's buffered-URL
// matcher in main.go.
func IsStreamURL(u string) bool {
_, ok := streamURLs.Load(u)
return ok
}
+24
View File
@@ -0,0 +1,24 @@
package qobuz
import "testing"
func TestRegisterAndIsStreamURL(t *testing.T) {
const u = "https://streaming-qobuz.example/file/abc123?sig=xyz"
if IsStreamURL(u) {
t.Fatalf("url should not be registered yet")
}
registerStreamURL(u)
if !IsStreamURL(u) {
t.Fatalf("url should be registered after registerStreamURL")
}
if IsStreamURL("https://other.example/track") {
t.Fatalf("unrelated url should not match")
}
}
func TestRegisterStreamURLEmpty(t *testing.T) {
registerStreamURL("")
if IsStreamURL("") {
t.Fatalf("empty url must never be registered")
}
}
+73
View File
@@ -0,0 +1,73 @@
package qobuz
import "encoding/json"
// apiArtist is the Qobuz artist object.
type apiArtist struct {
ID json.Number `json:"id"`
Name string `json:"name"`
AlbumsCount int `json:"albums_count"`
}
// apiGenre is the Qobuz genre object.
type apiGenre struct {
Name string `json:"name"`
}
// apiAlbum is the Qobuz album object. Tracks is populated only when the request
// asks for the "tracks" extra (e.g. album/get).
type apiAlbum struct {
ID string `json:"id"`
Title string `json:"title"`
TracksCount int `json:"tracks_count"`
Duration int `json:"duration"`
ReleaseDateOriginal string `json:"release_date_original"`
Genre apiGenre `json:"genre"`
Artist apiArtist `json:"artist"`
Tracks *apiTrackList `json:"tracks"`
}
// apiTrack is the Qobuz track object. Album is present in search and playlist
// responses but absent when the track is nested inside an album/get response.
type apiTrack struct {
ID json.Number `json:"id"`
Title string `json:"title"`
TrackNumber int `json:"track_number"`
Duration int `json:"duration"`
Streamable bool `json:"streamable"`
Performer apiArtist `json:"performer"`
Album *apiAlbum `json:"album"`
}
// apiTrackList is a paginated list of tracks.
type apiTrackList struct {
Items []apiTrack `json:"items"`
Total int `json:"total"`
}
// apiAlbumList is a paginated list of albums.
type apiAlbumList struct {
Items []apiAlbum `json:"items"`
Total int `json:"total"`
}
// apiArtistList is a paginated list of artists.
type apiArtistList struct {
Items []apiArtist `json:"items"`
Total int `json:"total"`
}
// apiPlaylist is the Qobuz playlist object.
type apiPlaylist struct {
ID json.Number `json:"id"`
Name string `json:"name"`
TracksCount int `json:"tracks_count"`
Duration int `json:"duration"`
Tracks *apiTrackList `json:"tracks"`
}
// apiPlaylistList is a paginated list of playlists.
type apiPlaylistList struct {
Items []apiPlaylist `json:"items"`
Total int `json:"total"`
}
+17 -1
View File
@@ -18,6 +18,7 @@ import (
"cliamp/external/navidrome"
"cliamp/external/netease"
"cliamp/external/plex"
"cliamp/external/qobuz"
"cliamp/external/radio"
"cliamp/external/radiometa"
"cliamp/external/soundcloud"
@@ -105,6 +106,12 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
}
}
var qobuzProv *qobuz.QobuzProvider
if cfg.Qobuz.IsSet() {
qobuzProv = qobuz.New(cfg.Qobuz.Quality)
providers = append(providers, model.ProviderEntry{Key: "qobuz", Name: "Qobuz", Provider: qobuzProv})
}
if scProv := soundcloud.NewFromConfig(soundcloud.Config{
Enabled: cfg.SoundCloud.Enabled,
User: cfg.SoundCloud.User,
@@ -173,6 +180,9 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
if spotifyProv != nil {
defer spotifyProv.Close()
}
if qobuzProv != nil {
defer qobuzProv.Close()
}
if ytProviders.Music != nil {
defer ytProviders.Music.Close()
}
@@ -259,7 +269,7 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
}
p.RegisterBufferedURLMatcher(func(u string) bool {
return navidrome.IsSubsonicStreamURL(u) || jellyfin.IsStreamURL(u) || emby.IsStreamURL(u) || plex.IsStreamURL(u)
return navidrome.IsSubsonicStreamURL(u) || jellyfin.IsStreamURL(u) || emby.IsStreamURL(u) || plex.IsStreamURL(u) || qobuz.IsStreamURL(u)
})
// Pull now-playing for stations that carry no inline ICY metadata (NTS, FIP).
@@ -384,6 +394,12 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
})
defer spotify.SetAuthURLObserver(nil)
}
if qobuzProv != nil {
qobuz.SetAuthURLObserver(func(u string) {
prog.Send(model.ProvAuthURLMsg{URL: u})
})
defer qobuz.SetAuthURLObserver(nil)
}
svc, svcErr := wireMediaCtl(prog)
if svcErr != nil {
+1
View File
@@ -35,4 +35,5 @@ const (
MetaJellyfinID = "jellyfin.id"
MetaEmbyID = "emby.id"
MetaNetEaseID = "netease.id"
MetaQobuzID = "qobuz.id"
)
+17 -9
View File
@@ -4,20 +4,20 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CLIAMP — Terminal Music Player</title>
<meta name="description" content="A retro terminal music player inspired by Winamp 2.x. Play local files, YouTube, Spotify, Plex, Jellyfin, Emby, Navidrome, SoundCloud, NetEase, and 30,000+ radio stations with a spectrum visualizer and 10-band EQ.">
<meta name="description" content="A retro terminal music player inspired by Winamp 2.x. Play local files, YouTube, Spotify, Qobuz, Plex, Jellyfin, Emby, Navidrome, SoundCloud, NetEase, and 30,000+ radio stations with a spectrum visualizer and 10-band EQ.">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content="CLIAMP — Terminal Music Player">
<meta property="og:description" content="A retro terminal music player inspired by Winamp 2.x. Play YouTube, Spotify, Plex, Jellyfin, Emby, SoundCloud, NetEase, and 30,000+ radio stations from your terminal with a spectrum visualizer and 10-band EQ.">
<meta property="og:description" content="A retro terminal music player inspired by Winamp 2.x. Play YouTube, Spotify, Qobuz, Plex, Jellyfin, Emby, SoundCloud, NetEase, and 30,000+ radio stations from your terminal with a spectrum visualizer and 10-band EQ.">
<meta property="og:image" content="https://cliamp.stream/og-image.png">
<meta property="og:url" content="https://cliamp.stream">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="CLIAMP — Terminal Music Player">
<meta name="twitter:description" content="A retro terminal music player inspired by Winamp 2.x. Play YouTube, Spotify, Plex, Jellyfin, Emby, SoundCloud, NetEase, and 30,000+ radio stations from your terminal with a spectrum visualizer and 10-band EQ.">
<meta name="twitter:description" content="A retro terminal music player inspired by Winamp 2.x. Play YouTube, Spotify, Qobuz, Plex, Jellyfin, Emby, SoundCloud, NetEase, and 30,000+ radio stations from your terminal with a spectrum visualizer and 10-band EQ.">
<meta name="twitter:image" content="https://cliamp.stream/og-image.png">
<style>
@@ -1262,7 +1262,7 @@
<div class="hero-tagline">Terminal Music Player · <em>Winamp</em> for your shell</div>
<p class="hero-desc">
Playlists, EQ, visualizers, lyrics, remote control, and a Lua plugin system.
Streams from <em>Spotify</em>, YouTube Music, NetEase, Plex, Jellyfin, Emby, Navidrome, and 30,000+ radio stations.
Streams from <em>Spotify</em>, Qobuz, YouTube Music, NetEase, Plex, Jellyfin, Emby, Navidrome, and 30,000+ radio stations.
</p>
<!-- Terminal (cliamp TUI simulation) -->
@@ -1393,6 +1393,7 @@
<span class="marquee-item"><strong>AAC</strong></span>
<span class="marquee-item"><strong>ALAC</strong></span>
<span class="marquee-item"><strong>Spotify</strong></span>
<span class="marquee-item"><strong>Qobuz</strong></span>
<span class="marquee-item"><strong>YouTube</strong></span>
<span class="marquee-item"><strong>Plex</strong></span>
<span class="marquee-item"><strong>Jellyfin</strong></span>
@@ -1416,6 +1417,7 @@
<span class="marquee-item"><strong>AAC</strong></span>
<span class="marquee-item"><strong>ALAC</strong></span>
<span class="marquee-item"><strong>Spotify</strong></span>
<span class="marquee-item"><strong>Qobuz</strong></span>
<span class="marquee-item"><strong>YouTube</strong></span>
<span class="marquee-item"><strong>Plex</strong></span>
<span class="marquee-item"><strong>Jellyfin</strong></span>
@@ -1485,7 +1487,7 @@
<div class="next-step">
<div class="next-step-label">Next step · configure providers</div>
<h3>Run the setup wizard</h3>
<p>An interactive TUI walks you through Navidrome, Plex, Jellyfin, Emby, Spotify, NetEase, and YouTube Music. It links to each provider's credential page, validates the connection, and writes the right block to your config file.</p>
<p>An interactive TUI walks you through Navidrome, Plex, Jellyfin, Emby, Spotify, Qobuz, NetEase, and YouTube Music. It links to each provider's credential page, validates the connection, and writes the right block to your config file.</p>
<div class="install-box" onclick="copyCmd(this,'cliamp setup')">
<div class="install-platform">Setup</div>
<code><span class="prompt">$ </span>cliamp setup</code>
@@ -1504,7 +1506,7 @@
<span class="sh-line"></span>
</div>
<p class="sources-intro">
Stream from everywhere. Every provider runs through the same <em>playlist, EQ, visualizer, and lyrics pipeline</em> — your config follows you across services. Run <code>cliamp setup</code> for an interactive wizard that walks you through Navidrome, Plex, Jellyfin, Emby, Spotify, NetEase, and YouTube Music.
Stream from everywhere. Every provider runs through the same <em>playlist, EQ, visualizer, and lyrics pipeline</em> — your config follows you across services. Run <code>cliamp setup</code> for an interactive wizard that walks you through Navidrome, Plex, Jellyfin, Emby, Spotify, Qobuz, NetEase, and YouTube Music.
</p>
<div class="sources-grid">
<div class="source" style="--src-color:#1db954">
@@ -1512,6 +1514,11 @@
<div class="source-name">Spotify</div>
<div class="source-desc">Stream your Premium library. Bring your own developer <code>client_id</code> for a private quota, or use the built-in shared one. Search &amp; add tracks with <kbd>F</kbd>. Currently unavailable on Windows builds.</div>
</div>
<div class="source" style="--src-color:#0a0a0a">
<div class="source-badge">OAuth · lossless</div>
<div class="source-name">Qobuz</div>
<div class="source-desc">Opt-in: set <code>[qobuz] enabled = true</code>. Stream lossless FLAC up to 24-bit/192kHz from your subscription. Browse favorites &amp; playlists, search with <kbd>Ctrl+F</kbd>. Sign in once via OAuth in your browser.</div>
</div>
<div class="source" style="--src-color:#ff0000">
<div class="source-badge">yt-dlp</div>
<div class="source-name">YouTube</div>
@@ -1971,7 +1978,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="keys-group-title">Search &amp; Browse</div>
<div class="keys-group-body">
<div class="key-row"><kbd>/</kbd><span>Search playlist (fuzzy; ↑ / ↓ or Ctrl+N / Ctrl+P move results; Ctrl+U / Ctrl+D page results)</span></div>
<div class="key-row"><kbd>Ctrl+F</kbd><span>Search active provider (Spotify, Navidrome, Jellyfin, Emby, Plex, NetEase, Local) or YouTube fallback; Local search is fuzzy</span></div>
<div class="key-row"><kbd>Ctrl+F</kbd><span>Search active provider (Spotify, Qobuz, Navidrome, Jellyfin, Emby, Plex, NetEase, Local) or YouTube fallback; Local search is fuzzy</span></div>
<div class="key-row"><kbd>f</kbd><span>Toggle bookmark &#9733; / radio favorite</span></div>
<div class="key-row"><kbd>u</kbd><span>Load URL (stream / playlist)</span></div>
<div class="key-row"><kbd>o</kbd><span>Open file browser</span></div>
@@ -2005,7 +2012,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="key-row"><kbd>/</kbd><span>Filter the visible playlists</span></div>
<div class="key-row"><kbd>Ctrl+F</kbd><span>Online / server search via the provider's own search</span></div>
<div class="key-row"><kbd>Ctrl+R</kbd><span>Refresh — re-pull playlists from the provider</span></div>
<div class="key-row"><kbd>S / N / P / J / E / Y / C / M / L / R</kbd><span>Switch to Spotify / Navidrome / Plex / Jellyfin / Emby / YouTube / SoundCloud / NetEase / Local / Radio</span></div>
<div class="key-row"><kbd>S / N / P / J / E / Y / C / M / Q / L / R</kbd><span>Switch to Spotify / Navidrome / Plex / Jellyfin / Emby / YouTube / SoundCloud / NetEase / Qobuz / Local / Radio</span></div>
<div class="key-row"><kbd></kbd><span>Marker on the row whose tracks are currently loaded</span></div>
</div>
</div>
@@ -2021,7 +2028,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="key-row"><kbd>q</kbd><span>Queue the highlighted track to play next</span></div>
<div class="key-row"><kbd>s</kbd><span>Cycle album sort (album list only)</span></div>
<div class="key-row"><kbd>/</kbd><span>Filter the visible list (search bar appears under the title)</span></div>
<div class="key-row"><kbd>S / N / P / J / E / Y / C / M / L / R</kbd><span>Quick-switch to another provider without going back to the main pane</span></div>
<div class="key-row"><kbd>S / N / P / J / E / Y / C / M / Q / L / R</kbd><span>Quick-switch to another provider without going back to the main pane</span></div>
<div class="key-row"><kbd>Esc</kbd><span>Walk back one level / close the browser</span></div>
</div>
</div>
@@ -2048,6 +2055,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="key-row"><kbd>J</kbd><span>Jellyfin</span></div>
<div class="key-row"><kbd>E</kbd><span>Emby</span></div>
<div class="key-row"><kbd>Y</kbd><span>YouTube</span></div>
<div class="key-row"><kbd>Q</kbd><span>Qobuz</span></div>
<div class="key-row"><kbd>R</kbd><span>Radio</span></div>
<div class="key-row"><kbd>e / t / v</kbd><span>EQ preset / Theme / Visualizer</span></div>
<div class="key-row"><kbd>Ctrl+V</kbd><span>Pick visualizer (live preview list)</span></div>
+2 -1
View File
@@ -53,6 +53,7 @@ var keymapEntries = []keymapEntry{
{key: "M", action: "Open NetEase provider"},
{key: "J", action: "Open Jellyfin provider"},
{key: "E", action: "Open Emby provider"},
{key: "Q", action: "Open Qobuz provider"},
{key: "Ctrl+J", action: "Jump to time"},
{key: "p", action: "Playlist manager"},
{key: "Ctrl+H", action: "Toggle album headers"},
@@ -103,7 +104,7 @@ var coreReservedKeys = []string{
"r", "z", "m", "e", "a", "A", "ctrl+h",
"ctrl+s", "S", "/", "ctrl+f",
"ctrl+j", "J", "E", "p", "t", "i", "y", "o", "u",
"N", "L", "R", "P", "Y", "C", "M",
"N", "L", "R", "P", "Y", "C", "M", "Q",
"v", "V", "ctrl+v", "ctrl+x", "x", "d", "ctrl+k", "?",
"ctrl+r",
}
+4
View File
@@ -368,6 +368,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
return m.switchToProvider("soundcloud")
case "M":
return m.switchToProvider("netease")
case "Q":
return m.switchToProvider("qobuz")
case "L":
return m.switchToProvider("local")
case "R":
@@ -748,6 +750,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
return m.switchToProvider("soundcloud")
case "M":
return m.switchToProvider("netease")
case "Q":
return m.switchToProvider("qobuz")
case "ctrl+h":
m.toggleAlbumHeadersManual()