Add NetEase Cloud Music provider (#222)

* netease: add provider

* netease: address review feedback

---------

Co-authored-by: bjarneo <bjarneo@users.noreply.github.com>
This commit is contained in:
Zander
2026-05-10 00:14:16 +08:00
committed by GitHub
parent 16a79bbea2
commit 41356e44ab
21 changed files with 1210 additions and 47 deletions
+5 -4
View File
@@ -1,4 +1,4 @@
A retro terminal music player inspired by Winamp. Play local files, streams, podcasts, YouTube, YouTube Music, SoundCloud, Bilibili, Spotify, Xiaoyuzhou (小宇宙), Navidrome, Plex, and Jellyfin 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, Bilibili, Spotify, NetEase Cloud Music, Xiaoyuzhou (小宇宙), Navidrome, Plex, and Jellyfin with a spectrum visualizer, parametric EQ, and playlist management.
**[cliamp.stream](https://cliamp.stream)**
@@ -50,7 +50,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, and Bilibili
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — for YouTube, YouTube Music, SoundCloud, Bandcamp, Bilibili, and NetEase Cloud Music
On macOS: `brew install ffmpeg yt-dlp`. On Linux, use your distribution's package manager.
@@ -70,7 +70,7 @@ cliamp https://example.com/stream # play a URL
Press `Ctrl+K` to see all keybindings.
**Configure remote providers** (Navidrome, Plex, Jellyfin, Spotify, YouTube Music) with the interactive wizard:
**Configure remote providers** (Navidrome, Plex, Jellyfin, Spotify, YouTube Music, NetEase Cloud Music) with the interactive wizard:
```sh
cliamp setup
@@ -128,7 +128,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, and Bilibili
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — for YouTube, SoundCloud, Bandcamp, Bilibili, and NetEase Cloud Music
## Docs
@@ -139,6 +139,7 @@ Or without Make: `go build -o cliamp .`
- [Playlists](docs/playlists.md)
- [YouTube, SoundCloud, Bandcamp and Bilibili](docs/yt-dlp.md)
- [YouTube Music](docs/youtube-music.md)
- [NetEase Cloud Music](docs/netease.md)
- [SoundCloud](docs/soundcloud.md)
- [Lyrics](docs/lyrics.md)
- [Spotify](docs/spotify.md)
+65 -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, YouTube Music),
// provider (Navidrome, Plex, Jellyfin, Spotify, NetEase, YouTube Music),
// validates the connection where possible, and writes the resulting
// TOML section to ~/.config/cliamp/config.toml.
//
@@ -10,6 +10,7 @@
package cmd
import (
"context"
"errors"
"fmt"
"io/fs"
@@ -27,6 +28,7 @@ import (
"cliamp/external/emby"
"cliamp/external/jellyfin"
"cliamp/external/navidrome"
"cliamp/external/netease"
"cliamp/external/plex"
"cliamp/internal/appdir"
)
@@ -87,6 +89,7 @@ type pickerOption struct {
const (
keyJellyfinAuth = "_auth"
keyEmbyAuth = "_emby_auth"
keyNetEaseBrowser = "_netease_browser"
keyYTMusicMode = "_mode"
keySpotifyMode = "_spotify_mode"
)
@@ -284,6 +287,59 @@ func providers() []providerSpec {
return strings.Join(lines, "\n")
},
},
{
key: "netease",
name: "NetEase Cloud Music",
section: "netease",
intro: []string{
"Reuses your browser session through yt-dlp cookies.",
"Sign in at music.163.com first, then pick that browser here.",
},
picker: &pickerSpec{
key: keyNetEaseBrowser,
label: "Browser session",
options: []pickerOption{
{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: "vivaldi", label: "Vivaldi"},
{value: "custom", label: "Custom browser/profile"},
},
},
fields: []fieldSpec{
{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[keyNetEaseBrowser] == "custom" }},
},
validate: func(v map[string]string) error {
browser := netEaseCookiesFrom(v)
if browser == "" {
return fmt.Errorf("browser is required")
}
v["cookies_from"] = browser
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
acc, err := netease.CheckLogin(ctx, browser)
if err != nil {
return fmt.Errorf("netease: validation: %w", err)
}
v["user_id"] = acc.UserID
return nil
},
body: func(v map[string]string) string {
browser := netEaseCookiesFrom(v)
lines := []string{
"enabled = true",
fmt.Sprintf("cookies_from = %q", browser),
}
if v["user_id"] != "" {
lines = append(lines, fmt.Sprintf("user_id = %q", v["user_id"]))
}
return strings.Join(lines, "\n")
},
},
{
key: "ytmusic",
name: "YouTube Music",
@@ -334,6 +390,14 @@ func providers() []providerSpec {
}
}
func netEaseCookiesFrom(v map[string]string) string {
picked := strings.TrimSpace(v[keyNetEaseBrowser])
if picked == "" || picked == "custom" {
return strings.TrimSpace(v["cookies_from"])
}
return picked
}
// ----- Bubbletea model ----------------------------------------------------
type stage int
+67
View File
@@ -222,6 +222,73 @@ func TestPasteIntoActiveField(t *testing.T) {
}
}
func TestNetEaseSetupBody(t *testing.T) {
spec := providerSpec{}
for _, p := range providers() {
if p.section == "netease" {
spec = p
break
}
}
if spec.section == "" {
t.Fatal("netease spec missing")
}
body := spec.body(map[string]string{
keyNetEaseBrowser: "chrome",
"user_id": "42",
})
for _, want := range []string{
"enabled = true",
`cookies_from = "chrome"`,
`user_id = "42"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q: %q", want, body)
}
}
}
func TestNetEasePickerSelectionFiltersFields(t *testing.T) {
base := newSetupModel()
neteaseIdx := -1
for i, p := range base.provs {
if p.section == "netease" {
neteaseIdx = i
break
}
}
if neteaseIdx < 0 {
t.Fatal("netease spec missing")
}
tests := []struct {
name string
browser string
wantVisible int
wantKey string
}{
{"chrome hides cookies_from", "chrome", 0, ""},
{"custom shows cookies_from", "custom", 1, "cookies_from"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m := newSetupModel()
m.pidx = neteaseIdx
m.values = map[string]string{keyNetEaseBrowser: tc.browser}
m.refreshVisibleFields()
if len(m.visible) != tc.wantVisible {
t.Fatalf("visible fields = %d, want %d", len(m.visible), tc.wantVisible)
}
if tc.wantVisible == 1 {
field := m.provs[neteaseIdx].fields[m.visible[0]]
if field.key != tc.wantKey {
t.Fatalf("field = %q, want %q", field.key, tc.wantKey)
}
}
})
}
}
// TestSaveSection covers the three write paths: new file, append, replace.
func TestSaveSection(t *testing.T) {
dir := t.TempDir()
+4 -4
View File
@@ -32,7 +32,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, yt, youtube, ytmusic"},
&cli.StringFlag{Name: "provider", Usage: "default provider: radio, navidrome, plex, jellyfin, emby, spotify, 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"},
@@ -149,10 +149,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", "yt", "youtube", "ytmusic":
case "radio", "navidrome", "spotify", "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, yt, youtube, or ytmusic (got %q)", v)
return ov, fmt.Errorf("--provider must be radio, navidrome, spotify, plex, jellyfin, emby, soundcloud, netease, yt, youtube, or ytmusic (got %q)", v)
}
}
if c.IsSet("start-theme") {
@@ -300,7 +300,7 @@ func setupCommand() *cli.Command {
Name: "setup",
Usage: "interactive wizard to configure remote providers",
Description: "Walks through configuring Navidrome, Plex, Jellyfin, Spotify,\n" +
"and YouTube Music. Validates connections and writes\n" +
"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()
+11 -1
View File
@@ -36,7 +36,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", or a YouTube provider
# Default provider on startup: "radio", "navidrome", "spotify", "plex", "jellyfin", "emby", "soundcloud", "netease", or a YouTube provider
# provider = "radio"
# Compact mode: cap UI width at 80 columns (default: fluid/full-width)
@@ -101,6 +101,16 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# user = "yourname"
# cookies_from = "firefox" # chrome, brave, edge, opera, safari, vivaldi…
# ---
# NetEase Cloud Music (optional, opt-in)
# Sign in to music.163.com in your browser, then run `cliamp setup` or set
# cookies_from manually. The provider lists your account playlists, saved
# playlists, liked songs, and public charts. Playback uses yt-dlp.
# [netease]
# enabled = true
# cookies_from = "chrome"
# user_id = "optional-account-user-id"
# ---
# Plex Media Server (optional)
# [plex]
+23 -1
View File
@@ -159,6 +159,18 @@ type SoundCloudConfig struct {
// IsSet reports whether the SoundCloud provider should be shown.
func (s SoundCloudConfig) IsSet() bool { return s.Enabled }
// NetEaseConfig holds settings for the NetEase Cloud Music provider.
// The provider is opt-in and can reuse an existing browser session through
// yt-dlp's --cookies-from-browser support.
type NetEaseConfig struct {
Enabled bool // true only when user explicitly sets enabled = true
CookiesFrom string // browser name for account APIs and playback (e.g. "chrome")
UserID string // optional account user id; setup can discover this from cookies
}
// IsSet reports whether the NetEase provider should be shown.
func (n NetEaseConfig) IsSet() bool { return n.Enabled }
// PlexConfig holds credentials for a Plex Media Server.
// Both URL and Token must be non-empty for a client to be constructed.
type PlexConfig struct {
@@ -215,7 +227,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", "ytmusic" (default "radio")
Provider string // default provider: "radio", "navidrome", "spotify", "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
@@ -235,6 +247,7 @@ type Config struct {
Jellyfin JellyfinConfig // optional Jellyfin server credentials
Emby EmbyConfig // optional Emby server credentials
SoundCloud SoundCloudConfig // SoundCloud provider (opt-in via enabled = true)
NetEase NetEaseConfig // NetEase Cloud Music provider (opt-in via enabled = true)
Plugins map[string]map[string]string // per-plugin config from [plugins.*] sections
LogLevel string // log level: debug, info, warn, error (default "info")
}
@@ -377,6 +390,15 @@ func Load() (Config, error) {
case "cookies_from":
cfg.SoundCloud.CookiesFrom = parseString(val)
}
case "netease":
switch key {
case "enabled":
cfg.NetEase.Enabled = strings.ToLower(val) == "true"
case "cookies_from":
cfg.NetEase.CookiesFrom = parseString(val)
case "user_id":
cfg.NetEase.UserID = parseString(val)
}
case "jellyfin":
switch key {
case "url":
+83
View File
@@ -0,0 +1,83 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadNetEase(t *testing.T) {
tests := []struct {
name string
env map[string]string
tomlContent string
wantEnabled bool
wantIsSet bool
wantCookies string
wantUserID string
}{
{
name: "disabled by default",
},
{
name: "enabled with explicit values",
tomlContent: `[netease]
enabled = true
cookies_from = "chrome"
user_id = "42"
`,
wantEnabled: true,
wantIsSet: true,
wantCookies: "chrome",
wantUserID: "42",
},
{
name: "cookies_from interpolated from env",
env: map[string]string{"NETEASE_BROWSER": "chrome"},
tomlContent: `[netease]
enabled = true
cookies_from = "$NETEASE_BROWSER"
`,
wantEnabled: true,
wantIsSet: true,
wantCookies: "chrome",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
for k, v := range tc.env {
t.Setenv(k, v)
}
if tc.tomlContent != "" {
configDir := filepath.Join(dir, ".config", "cliamp")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(tc.tomlContent), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.NetEase.Enabled != tc.wantEnabled {
t.Errorf("NetEase.Enabled = %v, want %v", cfg.NetEase.Enabled, tc.wantEnabled)
}
if cfg.NetEase.IsSet() != tc.wantIsSet {
t.Errorf("NetEase.IsSet() = %v, want %v", cfg.NetEase.IsSet(), tc.wantIsSet)
}
if cfg.NetEase.CookiesFrom != tc.wantCookies {
t.Errorf("CookiesFrom = %q, want %q", cfg.NetEase.CookiesFrom, tc.wantCookies)
}
if cfg.NetEase.UserID != tc.wantUserID {
t.Errorf("UserID = %q, want %q", cfg.NetEase.UserID, tc.wantUserID)
}
})
}
}
+2 -2
View File
@@ -65,7 +65,7 @@ cliamp search "never gonna give you up" # search YouTube
cliamp search-sc "lofi beats" # search SoundCloud
```
Press `Ctrl+F` in the player for context-aware search: it runs the active provider's native search (Spotify) or searches YouTube.
Press `Ctrl+F` in the player for context-aware search: it runs the active provider's native search when available or falls back to YouTube search.
## General
@@ -109,7 +109,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, 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, 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
+21 -2
View File
@@ -1,6 +1,6 @@
# Configuration
For remote providers (Navidrome, Plex, Jellyfin, Emby, Spotify, YouTube Music), the fastest path is the interactive wizard:
For remote providers (Navidrome, Plex, Jellyfin, Emby, Spotify, NetEase, YouTube Music), the fastest path is the interactive wizard:
```sh
cliamp setup
@@ -104,7 +104,7 @@ Set which provider to start with:
provider = "radio"
```
Valid values: `radio` (default), `navidrome`, `spotify`, `plex`, `jellyfin`, `emby`, `soundcloud`, `yt`, `youtube`, `ytmusic`.
Valid values: `radio` (default), `navidrome`, `spotify`, `plex`, `jellyfin`, `emby`, `soundcloud`, `netease`, `yt`, `youtube`, `ytmusic`.
You can also override from the CLI: `cliamp --provider jellyfin`.
@@ -148,6 +148,25 @@ With cookies set, yt-dlp can stream subscriber-gated tracks (SoundCloud Go+) and
Requires `yt-dlp` on `PATH`.
## NetEase Cloud Music
NetEase is opt-in and uses your existing browser session. Sign in at `music.163.com`, then run:
```sh
cliamp setup
```
Pick **NetEase Cloud Music** and choose the browser you used to sign in. Common browsers are shown as menu choices; select the custom option only for profile-specific values. The setup wizard validates the session and writes:
```toml
[netease]
enabled = true
cookies_from = "chrome"
user_id = "your-account-user-id"
```
Once enabled, the provider shows your liked songs, created playlists, saved playlists, and public charts. Search works with `Ctrl+F`, and playback uses `yt-dlp` with the same browser cookie source.
## Custom Radio Stations
Add your own stations to `~/.config/cliamp/radios.toml`:
+6 -5
View File
@@ -50,7 +50,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, Local) or YouTube fallback. Available from playlist and provider-browser views. |
| `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. |
| `u` | Load URL (stream/playlist) |
| `y` | Show lyrics |
| `Ctrl+S` | Save track to ~/Music |
@@ -63,6 +63,7 @@ Press `?` or `Ctrl+K` in the player to see all keybindings.
| `E` | Open Emby provider |
| `Y` | Open YouTube provider |
| `C` | Open SoundCloud provider |
| `M` | Open NetEase provider |
## Playlist and Queue
@@ -102,7 +103,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` `L` `R` | Quick-switch to that provider without going back through the main pane |
| `S` `N` `P` `J` `E` `Y` `C` `M` `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.
@@ -116,9 +117,9 @@ The playlists pane (visible when focus is on a provider — Spotify, Navidrome,
| `↑` `↓` / `j` `k` | Move cursor (wraps) |
| `Enter` | Load the highlighted playlist's tracks into the queue |
| `/` | Filter the playlist list |
| `Ctrl+F` | Online/server search (Spotify/Navidrome/etc.'s own search) |
| `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` `L` `R` | Switch to that provider |
| `S` `N` `P` `J` `E` `Y` `C` `M` `L` `R` | Switch to that provider |
| `Tab` | Switch focus to EQ |
| `Esc` `b` | Back to the playlist pane |
@@ -126,7 +127,7 @@ Playlist rows show `Name · N tracks · 1h 23m` when the provider returns track
## Search results overlays
When `Ctrl+F` opens the Spotify search or YouTube/SoundCloud net search and you're viewing the results list:
When `Ctrl+F` opens provider search or YouTube/SoundCloud net search and you're viewing the results list:
| Key | Action |
|---|---|
+63
View File
@@ -0,0 +1,63 @@
# NetEase Cloud Music Integration
cliamp supports NetEase Cloud Music as an opt-in provider. It can browse your account playlists, saved playlists, liked songs, and public charts. Playback is handled by `yt-dlp`, so `yt-dlp` and `ffmpeg` must be on `PATH`.
## Quick Start
Sign in at `music.163.com` in your browser, then run:
```sh
cliamp setup
```
Pick **NetEase Cloud Music**, then choose the browser where you are signed in. The wizard validates the session and writes:
```toml
[netease]
enabled = true
cookies_from = "chrome"
user_id = "your-account-user-id"
```
cliamp stores the browser name and user id only. It does not store your password or copy cookies into `config.toml`.
## Manual Config
```toml
[netease]
enabled = true
cookies_from = "chrome"
user_id = "78819429"
```
`cookies_from` is passed to `yt-dlp --cookies-from-browser`. Supported names depend on your `yt-dlp` version and commonly include `chrome`, `chromium`, `firefox`, `brave`, `edge`, `opera`, `safari`, and `vivaldi`. The setup wizard has common browsers as menu choices; use **Custom browser/profile** only for profile-specific values such as `chrome:Profile 1` or `firefox:default-release`.
`user_id` is optional when cookies are valid. If omitted, cliamp discovers it from the signed-in account.
## Usage
Start directly on NetEase:
```sh
cliamp --provider netease
```
Inside the TUI:
| Key | Action |
|---|---|
| `M` | Open NetEase provider |
| `Ctrl+F` | Search NetEase songs while NetEase is active |
| `Enter` | Load the highlighted playlist or play the highlighted track |
| `Ctrl+R` | Refresh playlists |
Direct NetEase URLs also work:
```sh
cliamp 'https://music.163.com/#/song?id=1973665667'
cliamp 'https://music.163.com/#/playlist?id=3778678'
```
## Limits
NetEase playback availability depends on the account, region, and track rights. If a song is unavailable upstream, cliamp surfaces the `yt-dlp` error. Using `cookies_from` gives `yt-dlp` the same account context as your browser, which improves access for tracks your account can play.
+4 -3
View File
@@ -1,10 +1,11 @@
# YouTube, SoundCloud, Bandcamp and Bilibili
# YouTube, SoundCloud, NetEase, Bandcamp and Bilibili
Play from YouTube, SoundCloud, Bandcamp, and Bilibili URLs if [yt-dlp](https://github.com/yt-dlp/yt-dlp) is installed:
Play from YouTube, SoundCloud, NetEase, Bandcamp, and Bilibili URLs if [yt-dlp](https://github.com/yt-dlp/yt-dlp) is installed:
```sh
cliamp https://www.youtube.com/watch?v=dQw4w9WgXcQ
cliamp https://soundcloud.com/artist/track
cliamp 'https://music.163.com/#/song?id=1973665667'
cliamp https://artist.bandcamp.com/album/name
cliamp https://www.bilibili.com/video/BV1xxxxxxxxx
cliamp https://space.bilibili.com/uid/lists/id # season/series playlists
@@ -21,7 +22,7 @@ cliamp search "never gonna give you up" # search YouTube
cliamp search-sc "lofi beats" # search SoundCloud
```
Inside the TUI, press `Ctrl+F` to search the active provider — YouTube when you're on YouTube/YT-Music, SoundCloud when you're on SoundCloud. SoundCloud also has dedicated provider docs covering profile browse and signed-in playback: [SoundCloud](soundcloud.md).
Inside the TUI, press `Ctrl+F` to search the active provider — YouTube when you're on YouTube/YT-Music, SoundCloud when you're on SoundCloud, and NetEase when you're on NetEase. SoundCloud and NetEase also have dedicated provider docs covering signed-in playback: [SoundCloud](soundcloud.md), [NetEase](netease.md).
## Disclaimer
+593
View File
@@ -0,0 +1,593 @@
// Package netease implements a playlist.Provider for NetEase Cloud Music.
package netease
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"time"
"cliamp/playlist"
"cliamp/provider"
"cliamp/resolve"
)
var (
_ playlist.Provider = (*Provider)(nil)
_ provider.Searcher = (*Provider)(nil)
)
const (
defaultAPIBase = "https://music.163.com"
probeURL = "https://music.163.com/#/playlist?id=3778678"
apiTimeout = 15 * time.Second
)
// ErrNotAuthenticated is returned when browser cookies do not contain a
// signed-in NetEase Cloud Music account.
var ErrNotAuthenticated = errors.New("netease: browser session is not signed in")
// Config holds settings for the NetEase provider.
type Config struct {
Enabled bool
CookiesFrom string
UserID string
}
// IsSet reports whether the provider should be exposed.
func (c Config) IsSet() bool { return c.Enabled }
// Account describes the signed-in NetEase account visible through cookies.
type Account struct {
UserID string
Nickname string
VIPType int
}
type chartPlaylist struct {
id string
name string
}
var charts = []chartPlaylist{
{id: "3778678", name: "Hot Songs"},
{id: "3779629", name: "New Songs"},
{id: "19723756", name: "Rising Songs"},
{id: "2884035", name: "Original Songs"},
}
// Provider implements playlist.Provider and provider.Searcher.
type Provider struct {
apiBase string
httpClient *http.Client
cookiesFrom string
userID string
mu sync.Mutex
cookieHeader string
playlists []playlist.PlaylistInfo
account *Account
}
// NewFromConfig returns a provider, or nil when NetEase is not enabled.
// Sets resolve's yt-dlp cookies as a side effect when CookiesFrom is non-empty
// so URL resolution uses the same signed-in browser session.
func NewFromConfig(cfg Config) *Provider {
if !cfg.Enabled {
return nil
}
cfg.CookiesFrom = strings.TrimSpace(cfg.CookiesFrom)
if cfg.CookiesFrom != "" {
resolve.SetYTDLCookiesFrom(cfg.CookiesFrom)
}
return New(cfg)
}
// New creates a NetEase provider.
func New(cfg Config) *Provider {
return &Provider{
apiBase: defaultAPIBase,
httpClient: &http.Client{Timeout: apiTimeout},
cookiesFrom: strings.TrimSpace(cfg.CookiesFrom),
userID: strings.TrimSpace(cfg.UserID),
}
}
func newWithBase(cfg Config, base string) *Provider {
p := New(cfg)
p.apiBase = strings.TrimRight(base, "/")
return p
}
func (p *Provider) Name() string { return "NetEase Cloud Music" }
// Refresh clears cached account and playlist state.
func (p *Provider) Refresh() {
p.mu.Lock()
defer p.mu.Unlock()
p.cookieHeader = ""
p.playlists = nil
p.account = nil
}
// CheckLogin verifies that the given browser has a signed-in NetEase account.
func CheckLogin(ctx context.Context, browser string) (Account, error) {
p := New(Config{Enabled: true, CookiesFrom: browser})
return p.Account(ctx)
}
// Account returns the signed-in account from browser cookies.
func (p *Provider) Account(ctx context.Context) (Account, error) {
p.mu.Lock()
if p.account != nil {
acc := *p.account
p.mu.Unlock()
return acc, nil
}
p.mu.Unlock()
var resp accountResponse
if err := p.apiGet(ctx, "/api/nuser/account/get", nil, &resp); err != nil {
return Account{}, err
}
if resp.Code != http.StatusOK {
return Account{}, fmt.Errorf("netease: account request failed with code %d", resp.Code)
}
uid := resp.Account.ID
if uid == 0 {
uid = resp.Profile.UserID
}
if uid == 0 {
return Account{}, ErrNotAuthenticated
}
acc := Account{
UserID: strconv.FormatInt(uid, 10),
Nickname: resp.Profile.Nickname,
VIPType: firstNonZero(resp.Profile.VIPType, resp.Account.VIPType),
}
p.mu.Lock()
p.account = &acc
if p.userID == "" {
p.userID = acc.UserID
}
p.mu.Unlock()
return acc, nil
}
// Playlists returns account playlists followed by public chart playlists.
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
p.mu.Lock()
if p.playlists != nil {
out := append([]playlist.PlaylistInfo(nil), p.playlists...)
p.mu.Unlock()
return out, nil
}
userID := p.userID
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()
var infos []playlist.PlaylistInfo
if userID == "" && p.cookiesFrom != "" {
acc, err := p.Account(ctx)
if err != nil {
return nil, err
}
userID = acc.UserID
}
if userID != "" {
userLists, err := p.userPlaylists(ctx, userID)
if err != nil {
return nil, err
}
infos = append(infos, userLists...)
}
infos = append(infos, chartPlaylists()...)
p.mu.Lock()
p.playlists = append([]playlist.PlaylistInfo(nil), infos...)
p.mu.Unlock()
return infos, nil
}
// Tracks returns tracks for a user playlist or built-in chart playlist.
func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) {
id, err := cleanPlaylistID(playlistID)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()
params := url.Values{"id": {id}}
var resp playlistDetailResponse
if err := p.apiGet(ctx, "/api/playlist/detail", params, &resp); err != nil {
return nil, err
}
if resp.Code != http.StatusOK {
return nil, fmt.Errorf("netease: playlist detail failed with code %d", resp.Code)
}
return songsToTracks(resp.Result.Tracks), nil
}
// SearchTracks searches NetEase songs.
func (p *Provider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) {
q := strings.TrimSpace(query)
if q == "" {
return nil, nil
}
if limit <= 0 {
limit = 20
}
params := url.Values{
"s": {q},
"type": {"1"},
"offset": {"0"},
"limit": {strconv.Itoa(limit)},
}
var resp searchResponse
if err := p.apiGet(ctx, "/api/search/get/web", params, &resp); err != nil {
return nil, err
}
if resp.Code != http.StatusOK {
return nil, fmt.Errorf("netease: search failed with code %d", resp.Code)
}
return songsToTracks(resp.Result.Songs), nil
}
func (p *Provider) userPlaylists(ctx context.Context, userID string) ([]playlist.PlaylistInfo, error) {
uid, err := strconv.ParseInt(strings.TrimSpace(userID), 10, 64)
if err != nil || uid <= 0 {
return nil, fmt.Errorf("netease: invalid user_id %q", userID)
}
const pageSize = 100
var out []playlist.PlaylistInfo
for offset := 0; ; offset += pageSize {
params := url.Values{
"uid": {strconv.FormatInt(uid, 10)},
"limit": {strconv.Itoa(pageSize)},
"offset": {strconv.Itoa(offset)},
}
var resp userPlaylistsResponse
if err := p.apiGet(ctx, "/api/user/playlist", params, &resp); err != nil {
return nil, err
}
if resp.Code != http.StatusOK {
return nil, fmt.Errorf("netease: playlist request failed with code %d", resp.Code)
}
for _, item := range resp.Playlist {
section := "Saved Playlists"
if item.UserID == uid {
section = "My Playlists"
}
name := strings.TrimSpace(item.Name)
if item.SpecialType == 5 {
name = "Liked Songs"
section = "My Playlists"
}
if name == "" {
name = "Untitled Playlist"
}
out = append(out, playlist.PlaylistInfo{
ID: "user:" + strconv.FormatInt(item.ID, 10),
Name: name,
TrackCount: item.TrackCount,
Section: section,
})
}
if len(resp.Playlist) < pageSize {
break
}
}
return out, nil
}
func chartPlaylists() []playlist.PlaylistInfo {
out := make([]playlist.PlaylistInfo, 0, len(charts))
for _, chart := range charts {
out = append(out, playlist.PlaylistInfo{
ID: "chart:" + chart.id,
Name: chart.name,
Section: "Charts",
})
}
return out
}
func cleanPlaylistID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", fmt.Errorf("netease: empty playlist id")
}
if v, ok := strings.CutPrefix(id, "user:"); ok {
id = v
} else if v, ok := strings.CutPrefix(id, "chart:"); ok {
id = v
}
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return "", fmt.Errorf("netease: invalid playlist id %q", id)
}
return id, nil
}
func songsToTracks(songs []song) []playlist.Track {
tracks := make([]playlist.Track, 0, len(songs))
for _, s := range songs {
if s.ID == 0 {
continue
}
tracks = append(tracks, playlist.Track{
Path: songURL(s.ID),
Title: s.Name,
Artist: joinArtists(s.Artists),
Album: s.Album.Name,
TrackNumber: s.TrackNumber,
Stream: true,
DurationSecs: millisToSeconds(s.DurationMS),
ProviderMeta: map[string]string{provider.MetaNetEaseID: strconv.FormatInt(s.ID, 10)},
})
}
return tracks
}
func songURL(id int64) string {
return "https://music.163.com/#/song?id=" + strconv.FormatInt(id, 10)
}
func joinArtists(artists []artist) string {
if len(artists) == 0 {
return ""
}
names := make([]string, 0, len(artists))
for _, a := range artists {
if name := strings.TrimSpace(a.Name); name != "" {
names = append(names, name)
}
}
return strings.Join(names, ", ")
}
func millisToSeconds(ms int) int {
if ms <= 0 {
return 0
}
return (ms + 999) / 1000
}
func firstNonZero(values ...int) int {
for _, v := range values {
if v != 0 {
return v
}
}
return 0
}
func (p *Provider) apiGet(ctx context.Context, path string, params url.Values, out any) error {
endpoint, err := url.Parse(p.apiBase + path)
if err != nil {
return err
}
if params != nil {
endpoint.RawQuery = params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Referer", p.apiBase+"/")
if header, err := p.ensureCookieHeader(ctx); err != nil {
return err
} else if header != "" {
req.Header.Set("Cookie", header)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("netease: http status %s", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("netease: decode response: %w", err)
}
return nil
}
func (p *Provider) ensureCookieHeader(ctx context.Context) (string, error) {
if p.cookiesFrom == "" {
return "", nil
}
p.mu.Lock()
if p.cookieHeader != "" {
header := p.cookieHeader
p.mu.Unlock()
return header, nil
}
p.mu.Unlock()
header, err := extractBrowserCookieHeader(ctx, p.cookiesFrom)
if err != nil {
return "", err
}
p.mu.Lock()
p.cookieHeader = header
p.mu.Unlock()
return header, nil
}
func extractBrowserCookieHeader(ctx context.Context, browser string) (string, error) {
if _, err := exec.LookPath("yt-dlp"); err != nil {
return "", fmt.Errorf("yt-dlp not found. Install with: %s", ytDLPInstallHint())
}
tmp, err := os.CreateTemp("", "cliamp-netease-cookies-*.txt")
if err != nil {
return "", err
}
path := tmp.Name()
tmp.Close()
os.Remove(path)
defer os.Remove(path)
cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "yt-dlp",
"--cookies-from-browser", browser,
"--cookies", path,
"--flat-playlist",
"--playlist-end", "1",
"--socket-timeout", "15",
"--print", "title",
probeURL,
)
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return "", fmt.Errorf("netease: load browser cookies: %s: %w", msg, err)
}
return "", fmt.Errorf("netease: load browser cookies: %w", err)
}
header, err := cookieHeaderFromNetscapeFile(path)
if err != nil {
return "", err
}
if header == "" {
return "", fmt.Errorf("netease: no NetEase cookies found in browser session")
}
return header, nil
}
func ytDLPInstallHint() string {
switch runtime.GOOS {
case "darwin":
return "brew install yt-dlp"
case "linux":
if _, err := exec.LookPath("apt-get"); err == nil {
return "sudo apt install yt-dlp"
}
if _, err := exec.LookPath("pacman"); err == nil {
return "sudo pacman -S yt-dlp"
}
return "pip install yt-dlp"
case "windows":
return "winget install yt-dlp"
default:
return "pip install yt-dlp"
}
}
func cookieHeaderFromNetscapeFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
seen := map[string]bool{}
var pairs []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "# Netscape") || strings.HasPrefix(line, "# This file") {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 7 {
continue
}
domain := strings.TrimPrefix(fields[0], "#HttpOnly_")
if !isNetEaseCookieDomain(domain) {
continue
}
name := fields[5]
value := fields[6]
if name == "" || seen[name] {
continue
}
seen[name] = true
pairs = append(pairs, name+"="+value)
}
if err := scanner.Err(); err != nil {
return "", err
}
return strings.Join(pairs, "; "), nil
}
func isNetEaseCookieDomain(domain string) bool {
domain = strings.TrimPrefix(strings.ToLower(domain), ".")
return domain == "163.com" || domain == "music.163.com" || strings.HasSuffix(domain, ".music.163.com")
}
type accountResponse struct {
Code int `json:"code"`
Account struct {
ID int64 `json:"id"`
VIPType int `json:"vipType"`
} `json:"account"`
Profile struct {
UserID int64 `json:"userId"`
Nickname string `json:"nickname"`
VIPType int `json:"vipType"`
} `json:"profile"`
}
type userPlaylistsResponse struct {
Code int `json:"code"`
Playlist []playlistItem `json:"playlist"`
}
type playlistItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
UserID int64 `json:"userId"`
TrackCount int `json:"trackCount"`
SpecialType int `json:"specialType"`
}
type playlistDetailResponse struct {
Code int `json:"code"`
Result struct {
Tracks []song `json:"tracks"`
} `json:"result"`
}
type searchResponse struct {
Code int `json:"code"`
Result struct {
Songs []song `json:"songs"`
} `json:"result"`
}
type song struct {
ID int64 `json:"id"`
Name string `json:"name"`
DurationMS int `json:"duration"`
TrackNumber int `json:"no"`
Artists []artist `json:"artists"`
Album album `json:"album"`
}
type artist struct {
Name string `json:"name"`
}
type album struct {
Name string `json:"name"`
}
+168
View File
@@ -0,0 +1,168 @@
package netease
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"cliamp/provider"
)
func TestPlaylistsIncludesAccountListsAndCharts(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/user/playlist" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("uid"); got != "42" {
t.Fatalf("uid = %q, want 42", got)
}
w.Write([]byte(`{"code":200,"playlist":[
{"id":10,"name":"Daily Picks","userId":42,"trackCount":12,"specialType":5},
{"id":11,"name":"Road Trip","userId":42,"trackCount":8,"specialType":0},
{"id":12,"name":"Saved Mix","userId":99,"trackCount":20,"specialType":0}
]}`))
}))
defer srv.Close()
p := newWithBase(Config{Enabled: true, UserID: "42"}, srv.URL)
lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists() error = %v", err)
}
if len(lists) != 7 {
t.Fatalf("got %d playlists, want 7", len(lists))
}
if lists[0].ID != "user:10" || lists[0].Name != "Liked Songs" || lists[0].Section != "My Playlists" {
t.Fatalf("liked playlist = %+v", lists[0])
}
if lists[2].Section != "Saved Playlists" {
t.Fatalf("saved playlist section = %q", lists[2].Section)
}
if lists[3].ID != "chart:3778678" || lists[3].Section != "Charts" {
t.Fatalf("first chart = %+v", lists[3])
}
}
func TestTracksMapsSongs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/playlist/detail" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("id"); got != "10" {
t.Fatalf("id = %q, want 10", got)
}
w.Write([]byte(`{"code":200,"result":{"tracks":[
{"id":100,"name":"First Track","duration":123456,"no":3,
"artists":[{"name":"Artist One"},{"name":"Artist Two"}],
"album":{"name":"Album One"}}
]}}`))
}))
defer srv.Close()
p := newWithBase(Config{Enabled: true}, srv.URL)
tracks, err := p.Tracks("user:10")
if err != nil {
t.Fatalf("Tracks() error = %v", err)
}
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(tracks))
}
tr := tracks[0]
if tr.Path != "https://music.163.com/#/song?id=100" {
t.Fatalf("Path = %q", tr.Path)
}
if tr.Artist != "Artist One, Artist Two" || tr.Album != "Album One" {
t.Fatalf("metadata = artist %q album %q", tr.Artist, tr.Album)
}
if tr.DurationSecs != 124 {
t.Fatalf("DurationSecs = %d, want 124", tr.DurationSecs)
}
if tr.Meta(provider.MetaNetEaseID) != "100" {
t.Fatalf("MetaNetEaseID = %q", tr.Meta(provider.MetaNetEaseID))
}
}
func TestSearchTracks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/search/get/web" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("s"); got != "query" {
t.Fatalf("search query = %q, want query", got)
}
if got := r.URL.Query().Get("limit"); got != "5" {
t.Fatalf("limit = %q, want 5", got)
}
w.Write([]byte(`{"code":200,"result":{"songs":[
{"id":200,"name":"Search Hit","duration":1000,
"artists":[{"name":"Artist"}],"album":{"name":"Album"}}
]}}`))
}))
defer srv.Close()
p := newWithBase(Config{Enabled: true}, srv.URL)
tracks, err := p.SearchTracks(context.Background(), " query ", 5)
if err != nil {
t.Fatalf("SearchTracks() error = %v", err)
}
if len(tracks) != 1 || tracks[0].Title != "Search Hit" {
t.Fatalf("tracks = %+v", tracks)
}
}
func TestCookieHeaderFromNetscapeFileFiltersNetEaseCookies(t *testing.T) {
path := t.TempDir() + "/cookies.txt"
data := strings.Join([]string{
"# Netscape HTTP Cookie File",
".music.163.com\tTRUE\t/\tTRUE\t0\tMUSIC_U\tabc",
"#HttpOnly_.163.com\tTRUE\t/\tTRUE\t0\t__csrf\tdef",
".example.com\tTRUE\t/\tTRUE\t0\tOTHER\tignored",
"",
}, "\n")
if err := osWriteFile(path, data); err != nil {
t.Fatal(err)
}
header, err := cookieHeaderFromNetscapeFile(path)
if err != nil {
t.Fatalf("cookieHeaderFromNetscapeFile() error = %v", err)
}
if header != "MUSIC_U=abc; __csrf=def" {
t.Fatalf("header = %q", header)
}
}
func TestExtractBrowserCookieHeaderMissingYTDLPShowsInstallHint(t *testing.T) {
t.Setenv("PATH", t.TempDir())
_, err := extractBrowserCookieHeader(context.Background(), "chrome")
if err == nil {
t.Fatal("extractBrowserCookieHeader() error = nil, want missing yt-dlp error")
}
msg := err.Error()
if !strings.HasPrefix(msg, "yt-dlp not found. Install with: ") {
t.Fatalf("error = %q", msg)
}
if strings.TrimPrefix(msg, "yt-dlp not found. Install with: ") == "" {
t.Fatalf("missing install hint in error = %q", msg)
}
}
func TestLiveCheckLoginWithBrowser(t *testing.T) {
browser := os.Getenv("CLIAMP_NETEASE_LIVE_BROWSER")
if browser == "" {
t.Skip("set CLIAMP_NETEASE_LIVE_BROWSER to run live browser-cookie check")
}
acc, err := CheckLogin(context.Background(), browser)
if err != nil {
t.Fatalf("CheckLogin() error = %v", err)
}
if acc.UserID == "" {
t.Fatal("CheckLogin() returned empty user id")
}
}
func osWriteFile(path, data string) error {
return os.WriteFile(path, []byte(data), 0o644)
}
+15 -3
View File
@@ -16,6 +16,7 @@ import (
"cliamp/external/jellyfin"
"cliamp/external/local"
"cliamp/external/navidrome"
"cliamp/external/netease"
"cliamp/external/plex"
"cliamp/external/radio"
"cliamp/external/soundcloud"
@@ -99,15 +100,26 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
User: cfg.SoundCloud.User,
CookiesFrom: cfg.SoundCloud.CookiesFrom,
}); scProv != nil {
// Mirror the cookies_from setting onto the player so streaming yt-dlp
// invocations use the same browser session as resolve. Last write wins
// if [ytmusic] cookies_from also set this earlier in run().
// Provider constructors configure resolve-side yt-dlp cookies. Mirror
// cookies_from onto the player so streaming yt-dlp invocations use the
// same browser session. Last write wins when multiple providers set it.
if cfg.SoundCloud.CookiesFrom != "" {
player.SetYTDLCookiesFrom(cfg.SoundCloud.CookiesFrom)
}
providers = append(providers, model.ProviderEntry{Key: "soundcloud", Name: "SoundCloud", Provider: scProv})
}
if neProv := netease.NewFromConfig(netease.Config{
Enabled: cfg.NetEase.Enabled,
CookiesFrom: cfg.NetEase.CookiesFrom,
UserID: cfg.NetEase.UserID,
}); neProv != nil {
if cfg.NetEase.CookiesFrom != "" {
player.SetYTDLCookiesFrom(cfg.NetEase.CookiesFrom)
}
providers = append(providers, model.ProviderEntry{Key: "netease", Name: "NetEase", Provider: neProv})
}
var ytProviders ytmusic.Providers
ytWanted := cfg.YouTubeMusic.IsSetOrFallback(ytmusic.FallbackCredentials)
if !ytWanted {
+1
View File
@@ -34,4 +34,5 @@ const (
MetaNavidromeID = "navidrome.id"
MetaJellyfinID = "jellyfin.id"
MetaEmbyID = "emby.id"
MetaNetEaseID = "netease.id"
)
+59 -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, 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, 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, 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, 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, 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, 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>
@@ -670,6 +670,39 @@
color:var(--dim-bright);font-size:11px;
line-height:1.55;
}
.source-config{
margin-top:12px;
padding:16px 18px;
border:1px solid var(--dim-2);
border-left:3px solid #d33a31;
background:rgba(211,58,49,0.04);
}
.source-config h3{
font-size:15px;color:var(--bright);
margin:0 0 6px 0;font-weight:700;
}
.source-config p{
font-size:12px;color:var(--fg);
margin:0 0 12px 0;line-height:1.6;
}
.source-config p:last-child{margin-bottom:0}
.source-config code{color:var(--green);font-size:11px}
.source-config kbd{
color:var(--amber);font-family:inherit;
padding:1px 6px;border:1px solid var(--border-hi);
background:var(--surface);font-size:10px;
}
.source-config pre{
margin:0 0 12px 0;
padding:12px 14px;
background:var(--bg);
border:1px solid var(--border);
color:var(--green);
font-family:var(--font-mono);
font-size:11px;
line-height:1.5;
overflow:auto;
}
/* ═══════════ FEATURES ═══════════ */
.features-grid{
@@ -1230,7 +1263,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, Plex, Jellyfin, Emby, Navidrome, and 30,000+ radio stations.
Streams from <em>Spotify</em>, YouTube Music, NetEase, Plex, Jellyfin, Emby, Navidrome, and 30,000+ radio stations.
</p>
<!-- Terminal (cliamp TUI simulation) -->
@@ -1367,6 +1400,7 @@
<span class="marquee-item"><strong>Emby</strong></span>
<span class="marquee-item"><strong>Navidrome</strong></span>
<span class="marquee-item"><strong>SoundCloud</strong></span>
<span class="marquee-item"><strong>NetEase</strong></span>
<span class="marquee-item"><strong>Bandcamp</strong></span>
<span class="marquee-item"><strong>Bilibili</strong></span>
<span class="marquee-item"><strong>RSS / Podcasts</strong></span>
@@ -1389,6 +1423,7 @@
<span class="marquee-item"><strong>Emby</strong></span>
<span class="marquee-item"><strong>Navidrome</strong></span>
<span class="marquee-item"><strong>SoundCloud</strong></span>
<span class="marquee-item"><strong>NetEase</strong></span>
<span class="marquee-item"><strong>Bandcamp</strong></span>
<span class="marquee-item"><strong>Bilibili</strong></span>
<span class="marquee-item"><strong>RSS / Podcasts</strong></span>
@@ -1451,7 +1486,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, 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, 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>
@@ -1470,7 +1505,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, 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, NetEase, and YouTube Music.
</p>
<div class="sources-grid">
<div class="source" style="--src-color:#1db954">
@@ -1513,6 +1548,11 @@
<div class="source-name">SoundCloud</div>
<div class="source-desc">Opt-in: set <code>[soundcloud] enabled = true</code>. Search with <kbd>Ctrl+F</kbd>, browse curated genre playlists, or add <code>user</code> for your profile. <code>cookies_from</code> unlocks Go+ tracks via your browser session.</div>
</div>
<div class="source" style="--src-color:#d33a31">
<div class="source-badge">Provider · yt-dlp</div>
<div class="source-name">NetEase</div>
<div class="source-desc">Opt-in: run <code>cliamp setup</code> after signing in at <code>music.163.com</code>, then choose your browser. Browse liked songs, account playlists, saved playlists, and charts with browser-cookie playback.</div>
</div>
<div class="source" style="--src-color:#629aa9">
<div class="source-badge">yt-dlp</div>
<div class="source-name">Bandcamp</div>
@@ -1539,6 +1579,16 @@
<div class="source-desc">MP3, FLAC, OGG, Opus, WAV, AAC, ALAC, WMA. ID3 tags read.</div>
</div>
</div>
<div class="source-config" id="netease-config">
<div class="next-step-label">NetEase Cloud Music</div>
<h3>Browser-session setup</h3>
<p>Sign in at <code>music.163.com</code>, then run <code>cliamp setup</code>. Choose NetEase Cloud Music, pick the browser you used, and the wizard validates the session before writing config.</p>
<pre><code>[netease]
enabled = true
cookies_from = "chrome"
user_id = "your-account-user-id"</code></pre>
<p>After setup, press <kbd>M</kbd> to open NetEase and <kbd>Ctrl+F</kbd> to search. Playback uses <code>yt-dlp</code> with the same browser cookie source.</p>
</div>
</div>
</section>
@@ -1917,7 +1967,7 @@
<div class="keys-group-title">Search &amp; Browse</div>
<div class="keys-group-body">
<div class="key-row"><kbd>/</kbd><span>Search playlist</span></div>
<div class="key-row"><kbd>Ctrl+F</kbd><span>Search active provider (Spotify, Navidrome, Jellyfin, Emby, Plex, Local) or YouTube fallback</span></div>
<div class="key-row"><kbd>Ctrl+F</kbd><span>Search active provider (Spotify, Navidrome, Jellyfin, Emby, Plex, NetEase, Local) or YouTube fallback</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>
@@ -1949,7 +1999,7 @@
<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</kbd> <kbd>N</kbd> <kbd>P</kbd> <kbd>J</kbd> <kbd>Y</kbd> <kbd>C</kbd> <kbd>L</kbd> <kbd>R</kbd><span>Switch to Spotify / Navidrome / Plex / Jellyfin / Emby/ YouTube / SoundCloud / Local / Radio</span></div>
<div class="key-row"><kbd>S</kbd> <kbd>N</kbd> <kbd>P</kbd> <kbd>J</kbd> <kbd>E</kbd> <kbd>Y</kbd> <kbd>C</kbd> <kbd>M</kbd> <kbd>L</kbd> <kbd>R</kbd><span>Switch to Spotify / Navidrome / Plex / Jellyfin / Emby / YouTube / SoundCloud / NetEase / Local / Radio</span></div>
<div class="key-row"><kbd></kbd><span>Marker on the row whose tracks are currently loaded</span></div>
</div>
</div>
@@ -1965,7 +2015,7 @@
<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</kbd> <kbd>N</kbd> <kbd>P</kbd> <kbd>J</kbd> <kbd>E</kbd> <kbd>Y</kbd> <kbd>L</kbd> <kbd>R</kbd><span>Quick-switch to another provider without going back to the main pane</span></div>
<div class="key-row"><kbd>S</kbd> <kbd>N</kbd> <kbd>P</kbd> <kbd>J</kbd> <kbd>E</kbd> <kbd>Y</kbd> <kbd>C</kbd> <kbd>M</kbd> <kbd>L</kbd> <kbd>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>
+2 -1
View File
@@ -50,6 +50,7 @@ var keymapEntries = []keymapEntry{
{key: "P", action: "Open Plex provider"},
{key: "Y", action: "Open YouTube provider"},
{key: "C", action: "Open SoundCloud provider"},
{key: "M", action: "Open NetEase provider"},
{key: "J", action: "Open Jellyfin provider"},
{key: "E", action: "Open Emby provider"},
{key: "Ctrl+J", action: "Jump to time"},
@@ -102,7 +103,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",
"N", "L", "R", "P", "Y", "C", "M",
"v", "V", "ctrl+x", "x", "d", "ctrl+k", "?",
"ctrl+r",
}
+4
View File
@@ -358,6 +358,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
return m.switchToProvider("spotify")
case "C":
return m.switchToProvider("soundcloud")
case "M":
return m.switchToProvider("netease")
case "L":
return m.switchToProvider("local")
case "R":
@@ -728,6 +730,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
return m.switchToProvider("yt")
case "C":
return m.switchToProvider("soundcloud")
case "M":
return m.switchToProvider("netease")
case "ctrl+h":
m.showAlbumHeaders = !m.showAlbumHeaders
+3 -1
View File
@@ -47,7 +47,7 @@ func (m *Model) switchProvider(idx int) tea.Cmd {
// quickSwitchProvider closes any browser overlays and jumps to the provider
// matched by key. Use the same Shift+letter shortcuts that switch providers
// from the main pane (S, N, P, J, Y, R, L). Returns nil when the key doesn't
// from the main pane (S, N, P, J, E, Y, M, R, L). Returns nil when the key doesn't
// match a known provider.
func (m *Model) quickSwitchProvider(key string) tea.Cmd {
provKey := providerKeyForShortcut(key)
@@ -77,6 +77,8 @@ func providerKeyForShortcut(key string) string {
return "emby"
case "Y":
return "yt"
case "M":
return "netease"
case "L":
return "local"
case "R":
+11 -10
View File
@@ -32,16 +32,17 @@ var (
// providerEmptyStateHint, keyed by lowercase provider Name(), returns the
// remediation hint shown under the generic "No playlists in X" message.
var providerEmptyStateHint = map[string]string{
"local playlists": "Add .toml playlists to ~/.config/cliamp/playlists/.",
"local": "Add .toml playlists to ~/.config/cliamp/playlists/.",
"spotify": "Sign in via Spotify, or check SPOTIFY_REFRESH_TOKEN.",
"navidrome": "Verify [navidrome] url/username/password in config.toml.",
"jellyfin": "Verify [jellyfin] url and token in config.toml.",
"emby": "Verify [emby] url and token or username/password in config.toml.",
"plex": "Verify [plex] server URL and token or library filter in config.toml.",
"youtube music": "Run `cliamp ytmusic-login` to authorize, then refresh.",
"ytmusic": "Run `cliamp ytmusic-login` to authorize, then refresh.",
"soundcloud": "Set [soundcloud] user in config.toml to browse a profile.",
"local playlists": "Add .toml playlists to ~/.config/cliamp/playlists/.",
"local": "Add .toml playlists to ~/.config/cliamp/playlists/.",
"spotify": "Sign in via Spotify, or check SPOTIFY_REFRESH_TOKEN.",
"navidrome": "Verify [navidrome] url/username/password in config.toml.",
"jellyfin": "Verify [jellyfin] url and token in config.toml.",
"emby": "Verify [emby] url and token or username/password in config.toml.",
"plex": "Verify [plex] server URL and token or library filter in config.toml.",
"youtube music": "Run `cliamp ytmusic-login` to authorize, then refresh.",
"ytmusic": "Run `cliamp ytmusic-login` to authorize, then refresh.",
"soundcloud": "Set [soundcloud] user in config.toml to browse a profile.",
"netease cloud music": "Run `cliamp setup` and configure NetEase browser cookies.",
}
// renderProviderEmptyState explains why the playlists pane is empty for the