Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[![Docs on contextowl.co](https://contextowl.co/uploads/_brand/badge-docs.svg)](https://contextowl.co)

A retro terminal music player inspired by Winamp. Play local files, streams, podcasts, YouTube, YouTube Music, SoundCloud, Bilibili, Spotify, NetEase Cloud Music, Xiaoyuzhou (小宇宙), Navidrome, Plex, Jellyfin, and Audiobookshelf with a spectrum visualizer, parametric EQ, and playlist management.
A retro terminal music player inspired by Winamp. Play local files, streams, podcasts, YouTube, YouTube Music, SoundCloud, Mixcloud, Bilibili, Spotify, NetEase Cloud Music, Xiaoyuzhou (小宇宙), Navidrome, Plex, Jellyfin, and Audiobookshelf with a spectrum visualizer, parametric EQ, and playlist management.

**[cliamp.stream](https://cliamp.stream)** · **[docs](https://whiterose.org.contextowl.co/docs/cliamp)**

Expand Down Expand Up @@ -84,7 +84,7 @@ Download from [GitHub Releases](https://github.com/bjarneo/cliamp/releases/lates
**Optional runtime dependencies** (all platforms, all install methods):

- [ffmpeg](https://ffmpeg.org/) — for AAC, ALAC, Opus, and WMA playback
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — for YouTube, YouTube Music, SoundCloud, Bandcamp, Bilibili, and NetEase Cloud Music
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — for YouTube, YouTube Music, SoundCloud, Mixcloud, Bandcamp, Bilibili, and NetEase Cloud Music

On macOS: `brew install ffmpeg yt-dlp`. On Linux, use your distribution's package manager.

Expand All @@ -106,13 +106,17 @@ cliamp https://example.com/stream # play a URL

Press `Ctrl+K` to see all keybindings.

**Configure remote providers** (Navidrome, Plex, Jellyfin, Audiobookshelf, Spotify, YouTube Music, NetEase Cloud Music) with the interactive wizard:
**Configure remote providers** (Navidrome, Plex, Jellyfin, Audiobookshelf, Spotify, Mixcloud, YouTube Music, NetEase Cloud Music) with the interactive wizard:

```sh
cliamp setup
```

It walks you through each provider, validates the connection, and writes the right block to your config file (`~/.config/cliamp/config.toml`, or `%APPDATA%\cliamp\config.toml` on Windows when `HOME` is unset). See [docs/cli.md](docs/cli.md#setup-wizard) for details.
It walks you through each provider and writes the right block to your config file (`~/.config/cliamp/config.toml`, or `%APPDATA%\cliamp\config.toml` on Windows when `HOME` is unset). Supported server connections are validated during setup; Mixcloud's optional browser-session or OAuth credentials are checked later, when used. See [docs/cli.md](docs/cli.md#setup-wizard) for details.

The [Mixcloud provider guide](docs/mixcloud.md) covers its complete discovery,
account, creator/show, genre search and local genre-favorite features, along
with authentication, signed-in playback, resume/seeking and limitations.

## Radio

Expand Down Expand Up @@ -185,7 +189,7 @@ Or without Make: `go build -o cliamp .`
**Optional runtime dependencies:**

- [ffmpeg](https://ffmpeg.org/) — for AAC, ALAC, Opus, and WMA playback
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — for YouTube, SoundCloud, Bandcamp, Bilibili, and NetEase Cloud Music
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — for YouTube, SoundCloud, Mixcloud, Bandcamp, Bilibili, and NetEase Cloud Music

## Docs

Expand Down
125 changes: 117 additions & 8 deletions cmd/setup.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// 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, Qobuz, NetEase, YouTube Music),
// provider (Navidrome, Plex, Jellyfin, Spotify, Qobuz, Mixcloud, NetEase,
// YouTube Music),
// validates the connection where possible, and writes the resulting
// TOML section to ~/.config/cliamp/config.toml.
//
Expand All @@ -28,6 +29,7 @@ import (
"github.com/bjarneo/cliamp/external/audiobookshelf"
"github.com/bjarneo/cliamp/external/emby"
"github.com/bjarneo/cliamp/external/jellyfin"
"github.com/bjarneo/cliamp/external/mixcloud"
"github.com/bjarneo/cliamp/external/navidrome"
"github.com/bjarneo/cliamp/external/netease"
"github.com/bjarneo/cliamp/external/plex"
Expand Down Expand Up @@ -88,13 +90,14 @@ type pickerOption struct {
// Picker keys are stored in the values map alongside real field keys; the
// leading underscore distinguishes them from TOML field names.
const (
keyJellyfinAuth = "_auth"
keyEmbyAuth = "_emby_auth"
keyABSAuth = "_abs_auth"
keyNetEaseBrowser = "_netease_browser"
keyYTMusicMode = "_mode"
keySpotifyMode = "_spotify_mode"
keyQobuzQuality = "_qobuz_quality"
keyJellyfinAuth = "_auth"
keyEmbyAuth = "_emby_auth"
keyABSAuth = "_abs_auth"
keyNetEaseBrowser = "_netease_browser"
keyYTMusicMode = "_mode"
keySpotifyMode = "_spotify_mode"
keyQobuzQuality = "_qobuz_quality"
keyMixcloudBrowser = "_mixcloud_browser"
)

func providers() []providerSpec {
Expand Down Expand Up @@ -422,6 +425,88 @@ func providers() []providerSpec {
return strings.Join(lines, "\n")
},
},
{
key: "mixcloud",
name: "Mixcloud",
section: "mixcloud",
intro: []string{
"Browse and play Mixcloud shows through its public API and yt-dlp.",
"A username adds your public library, following stream, and creators.",
"An optional developer access token adds /me and Listen Later.",
"Choose browser cookies for subscriber-only or signed-in playback.",
"Docs: cliamp.stream → docs/mixcloud.md",
},
picker: &pickerSpec{
key: keyMixcloudBrowser,
label: "Playback session",
options: []pickerOption{
{value: "none", label: "Public shows only (no browser cookies)"},
{value: "chrome", label: "Chrome"},
{value: "safari", label: "Safari"},
{value: "firefox", label: "Firefox"},
{value: "brave", label: "Brave"},
{value: "edge", label: "Edge"},
{value: "chromium", label: "Chromium"},
{value: "opera", label: "Opera"},
{value: "vivaldi", label: "Vivaldi"},
{value: "whale", label: "Whale"},
{value: "custom", label: "Custom browser/profile"},
},
},
fields: []fieldSpec{
{key: "username", label: "Mixcloud username (optional)", help: "adds account views and followed creators"},
{key: "access_token", label: "Developer API access token (optional)", help: "enables /me and Listen Later", secret: true},
{key: "cookies_from", label: "Custom browser/profile", help: "e.g. chrome:Profile 1, firefox:default-release", required: true,
onlyIf: func(v map[string]string) bool { return v[keyMixcloudBrowser] == "custom" }},
{key: "styles", label: "Music styles (optional)", help: "comma-separated; blank uses cliamp defaults"},
{key: "max_items", label: "Items per view", help: fmt.Sprintf("%d-%d", mixcloud.MinItems, mixcloud.MaxItemsLimit), defaultV: strconv.Itoa(mixcloud.DefaultMaxItems)},
{key: "stream_creators", label: "Creators in Stream", help: fmt.Sprintf("%d-%d", mixcloud.MinItems, mixcloud.MaxStreamCreators), defaultV: strconv.Itoa(mixcloud.DefaultStreamCreators)},
},
extraValidate: func(v map[string]string) error {
limits := []struct {
key, label string
max int
}{
{key: "max_items", label: "items per view", max: mixcloud.MaxItemsLimit},
{key: "stream_creators", label: "stream creators", max: mixcloud.MaxStreamCreators},
}
for _, field := range limits {
n, err := strconv.Atoi(strings.TrimSpace(v[field.key]))
if err != nil {
return fmt.Errorf("%s must be a number", field.label)
}
if n < mixcloud.MinItems || n > field.max {
return fmt.Errorf("%s is outside its supported range", field.label)
}
}
return nil
},
body: func(v map[string]string) string {
lines := []string{"enabled = true"}
if username := strings.TrimSpace(v["username"]); username != "" {
lines = append(lines, fmt.Sprintf("username = %q", username))
}
if token := strings.TrimSpace(v["access_token"]); token != "" {
lines = append(lines, fmt.Sprintf("access_token = %q", token))
}
if browser := mixcloudCookiesFrom(v); browser != "" {
lines = append(lines, fmt.Sprintf("cookies_from = %q", browser))
}
if styles := setupStringList(v["styles"]); styles != "" {
lines = append(lines, "styles = "+styles)
}
maxItems := strings.TrimSpace(v["max_items"])
if maxItems == "" {
maxItems = strconv.Itoa(mixcloud.DefaultMaxItems)
}
streamCreators := strings.TrimSpace(v["stream_creators"])
if streamCreators == "" {
streamCreators = strconv.Itoa(mixcloud.DefaultStreamCreators)
}
lines = append(lines, "max_items = "+maxItems, "stream_creators = "+streamCreators)
return strings.Join(lines, "\n")
},
},
{
key: "ytmusic",
name: "YouTube Music",
Expand Down Expand Up @@ -483,6 +568,30 @@ func netEaseCookiesFrom(v map[string]string) string {
return picked
}

func mixcloudCookiesFrom(v map[string]string) string {
picked := strings.TrimSpace(v[keyMixcloudBrowser])
if picked == "" || picked == "none" {
return ""
}
if picked == "custom" {
return strings.TrimSpace(v["cookies_from"])
}
return picked
}

func setupStringList(value string) string {
var quoted []string
for _, part := range strings.Split(value, ",") {
if part = strings.TrimSpace(part); part != "" {
quoted = append(quoted, strconv.Quote(part))
}
}
if len(quoted) == 0 {
return ""
}
return "[" + strings.Join(quoted, ", ") + "]"
}

// ----- Bubbletea model ----------------------------------------------------

type stage int
Expand Down
59 changes: 59 additions & 0 deletions cmd/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,65 @@ func TestQobuzSetupBody(t *testing.T) {
}
}

func TestMixcloudSetupBody(t *testing.T) {
var spec providerSpec
for _, p := range providers() {
if p.section == "mixcloud" {
spec = p
break
}
}
if spec.section == "" {
t.Fatal("mixcloud spec missing")
}
values := map[string]string{
keyMixcloudBrowser: "firefox",
"username": "alice",
"access_token": "token",
"styles": "ambient, deep-house",
"max_items": " 75 ",
"stream_creators": "15",
}
if err := spec.extraValidate(values); err != nil {
t.Fatalf("extraValidate: %v", err)
}
body := spec.body(values)
for _, want := range []string{
"enabled = true", `username = "alice"`, `access_token = "token"`,
`cookies_from = "firefox"`, `styles = ["ambient", "deep-house"]`,
"max_items = 75", "stream_creators = 15",
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q: %q", want, body)
}
}
publicOnly := spec.body(map[string]string{
keyMixcloudBrowser: "none",
"max_items": "100",
"stream_creators": "20",
})
if strings.Contains(publicOnly, "cookies_from") {
t.Fatalf("public-only session must not write cookies_from: %q", publicOnly)
}
custom := spec.body(map[string]string{
keyMixcloudBrowser: "custom",
"cookies_from": "chrome:Profile 1",
"max_items": "100",
"stream_creators": "20",
})
if !strings.Contains(custom, `cookies_from = "chrome:Profile 1"`) {
t.Fatalf("custom browser/profile was not written: %q", custom)
}
if spec.validate != nil {
t.Fatal("mixcloud setup should not claim a live validation probe")
}

err := spec.extraValidate(map[string]string{"max_items": "bad", "stream_creators": "also bad"})
if err == nil || !strings.Contains(err.Error(), "items per view") {
t.Fatalf("validation order error = %v, want items per view first", err)
}
}
Comment thread
kingsleyfaulkner marked this conversation as resolved.

func TestNetEasePickerSelectionFiltersFields(t *testing.T) {
base := newSetupModel()
neteaseIdx := -1
Expand Down
6 changes: 3 additions & 3 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,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, qobuz, soundcloud, netease, audiobookshelf, abs, yt, youtube, ytmusic"},
&cli.StringFlag{Name: "provider", Usage: "default provider: radio, navidrome, plex, jellyfin, emby, spotify, qobuz, soundcloud, mixcloud, netease, audiobookshelf, abs, 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 @@ -158,10 +158,10 @@ func overridesFromFlags(c *cli.Command) (config.Overrides, error) {
v = "audiobookshelf"
}
switch v {
case "radio", "navidrome", "spotify", "qobuz", "plex", "jellyfin", "emby", "audiobookshelf", "soundcloud", "netease", "yt", "youtube", "ytmusic":
case "radio", "navidrome", "spotify", "qobuz", "plex", "jellyfin", "emby", "audiobookshelf", "soundcloud", "mixcloud", "netease", "yt", "youtube", "ytmusic":
ov.Provider = &v
default:
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, qobuz, plex, jellyfin, emby, audiobookshelf, soundcloud, netease, yt, youtube, or ytmusic (got %q)", v)
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, qobuz, plex, jellyfin, emby, audiobookshelf, soundcloud, mixcloud, netease, yt, youtube, or ytmusic (got %q)", v)
}
}
if c.IsSet("start-theme") {
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"
# Saved Custom curve; applied 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", "qobuz", "plex", "jellyfin", "emby", "soundcloud", "netease", "audiobookshelf", or a YouTube provider
# Default provider on startup: "radio", "navidrome", "spotify", "qobuz", "plex", "jellyfin", "emby", "soundcloud", "mixcloud", "netease", "audiobookshelf", or a YouTube provider
# provider = "radio"

# Compact mode: cap UI width at 80 columns (default: fluid/full-width)
Expand Down Expand Up @@ -130,6 +130,24 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# user = "yourname"
# cookies_from = "firefox" # chrome, brave, edge, opera, safari, vivaldi…

# ---
# Mixcloud (optional, opt-in)
# Public discovery, show search, the live genre catalogue, genre/tag search,
# creator jumps, and Latest/Popular charts work with enabled=true. Set username
# for your following stream, profile activity, uploads, read-only show favorites,
# listening history, collections, and followed-creator browsing. A developer
# OAuth access_token makes /me the account identity and adds Listen Later.
# cookies_from is used by yt-dlp for signed-in playback only. styles is the
# local genre-favorites list; toggle it with [f] in Mixcloud's Genres browser.
# [mixcloud]
# enabled = true
# username = "yourname"
# access_token = "${MIXCLOUD_ACCESS_TOKEN}"
# cookies_from = "firefox" # chrome, brave, edge, chromium, opera, safari, vivaldi, whale…
# styles = ["ambient", "deep-house", "house", "jazz", "techno"]
# max_items = 100
# stream_creators = 20

# ---
# NetEase Cloud Music (optional, opt-in)
# Sign in to music.163.com in your browser, then run `cliamp setup` or set
Expand Down
Loading