1179 lines
33 KiB
Go
1179 lines
33 KiB
Go
// 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),
|
|
// validates the connection where possible, and writes the resulting
|
|
// TOML section to ~/.config/cliamp/config.toml.
|
|
//
|
|
// The UI is a small standalone Bubbletea+Lipgloss program — separate from
|
|
// the main player Model — because setup runs to completion and exits.
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"github.com/bjarneo/cliamp/external/emby"
|
|
"github.com/bjarneo/cliamp/external/jellyfin"
|
|
"github.com/bjarneo/cliamp/external/navidrome"
|
|
"github.com/bjarneo/cliamp/external/netease"
|
|
"github.com/bjarneo/cliamp/external/plex"
|
|
"github.com/bjarneo/cliamp/internal/appdir"
|
|
)
|
|
|
|
// Setup launches the interactive wizard. Returns nil on clean exit.
|
|
func Setup() error {
|
|
prog := tea.NewProgram(newSetupModel())
|
|
_, err := prog.Run()
|
|
return err
|
|
}
|
|
|
|
// ----- Provider specs -----------------------------------------------------
|
|
|
|
type fieldSpec struct {
|
|
key string
|
|
label string
|
|
help string // shown faintly under the field
|
|
required bool
|
|
secret bool
|
|
defaultV string
|
|
// onlyIf, when non-nil, hides the field unless the predicate returns true
|
|
// against the current values map. Used by Jellyfin for token-vs-password.
|
|
onlyIf func(map[string]string) bool
|
|
}
|
|
|
|
type providerSpec struct {
|
|
key string
|
|
name string
|
|
section string
|
|
intro []string // pre-form blurb (with help URLs)
|
|
picker *pickerSpec
|
|
fields []fieldSpec
|
|
// validate runs a probe against the live server. Nil skips validation.
|
|
validate func(map[string]string) error
|
|
// body returns the TOML block body (no header).
|
|
body func(map[string]string) string
|
|
// extraValidate runs after the form before validate, e.g. to enforce
|
|
// "token OR (user+password)" for Jellyfin.
|
|
extraValidate func(map[string]string) error
|
|
}
|
|
|
|
// pickerSpec is an optional radio choice shown before the fields. The
|
|
// selected option's key is stored in values[key]. fieldSpec.onlyIf can
|
|
// reference this to show different fields per choice.
|
|
type pickerSpec struct {
|
|
key string
|
|
label string
|
|
options []pickerOption
|
|
}
|
|
|
|
type pickerOption struct {
|
|
value string
|
|
label string
|
|
}
|
|
|
|
// 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"
|
|
keyNetEaseBrowser = "_netease_browser"
|
|
keyYTMusicMode = "_mode"
|
|
keySpotifyMode = "_spotify_mode"
|
|
keyQobuzQuality = "_qobuz_quality"
|
|
)
|
|
|
|
func providers() []providerSpec {
|
|
return []providerSpec{
|
|
{
|
|
key: "navidrome",
|
|
name: "Navidrome / Subsonic",
|
|
section: "navidrome",
|
|
intro: []string{
|
|
"Self-hosted music server using the Subsonic API.",
|
|
"Docs: cliamp.stream → docs/navidrome.md",
|
|
},
|
|
fields: []fieldSpec{
|
|
{key: "url", label: "Server URL", help: "e.g. https://music.example.com", required: true},
|
|
{key: "user", label: "Username", required: true},
|
|
{key: "password", label: "Password", required: true, secret: true},
|
|
},
|
|
validate: func(v map[string]string) error {
|
|
return navidrome.New(v["url"], v["user"], v["password"]).Ping()
|
|
},
|
|
body: func(v map[string]string) string {
|
|
return strings.Join([]string{
|
|
fmt.Sprintf("url = %q", v["url"]),
|
|
fmt.Sprintf("user = %q", v["user"]),
|
|
fmt.Sprintf("password = %q", v["password"]),
|
|
}, "\n")
|
|
},
|
|
},
|
|
{
|
|
key: "plex",
|
|
name: "Plex Media Server",
|
|
section: "plex",
|
|
intro: []string{
|
|
"Stream from your Plex server using an X-Plex-Token.",
|
|
"Find a token: https://support.plex.tv/articles/204059436",
|
|
},
|
|
fields: []fieldSpec{
|
|
{key: "url", label: "Server URL", help: "e.g. http://192.168.1.10:32400", required: true},
|
|
{key: "token", label: "X-Plex-Token", required: true, secret: true},
|
|
},
|
|
validate: func(v map[string]string) error {
|
|
return plex.NewClient(v["url"], v["token"]).Ping()
|
|
},
|
|
body: func(v map[string]string) string {
|
|
return strings.Join([]string{
|
|
fmt.Sprintf("url = %q", v["url"]),
|
|
fmt.Sprintf("token = %q", v["token"]),
|
|
}, "\n")
|
|
},
|
|
},
|
|
{
|
|
key: "jellyfin",
|
|
name: "Jellyfin",
|
|
section: "jellyfin",
|
|
intro: []string{
|
|
"Authenticate with an API token (Dashboard → API Keys)",
|
|
"or with your username and password.",
|
|
},
|
|
picker: &pickerSpec{
|
|
key: keyJellyfinAuth,
|
|
label: "Authentication",
|
|
options: []pickerOption{
|
|
{value: "token", label: "API token"},
|
|
{value: "password", label: "Username + password"},
|
|
},
|
|
},
|
|
fields: []fieldSpec{
|
|
{key: "url", label: "Server URL", help: "e.g. https://jellyfin.example.com", required: true},
|
|
{key: "token", label: "API token", required: true, secret: true,
|
|
onlyIf: func(v map[string]string) bool { return v[keyJellyfinAuth] == "token" }},
|
|
{key: "user", label: "Username", required: true,
|
|
onlyIf: func(v map[string]string) bool { return v[keyJellyfinAuth] == "password" }},
|
|
{key: "password", label: "Password", required: true, secret: true,
|
|
onlyIf: func(v map[string]string) bool { return v[keyJellyfinAuth] == "password" }},
|
|
},
|
|
validate: func(v map[string]string) error {
|
|
return jellyfin.NewClient(v["url"], v["token"], "", v["user"], v["password"]).Ping()
|
|
},
|
|
body: func(v map[string]string) string {
|
|
lines := []string{fmt.Sprintf("url = %q", v["url"])}
|
|
if v[keyJellyfinAuth] == "token" {
|
|
lines = append(lines, fmt.Sprintf("token = %q", v["token"]))
|
|
} else {
|
|
lines = append(lines,
|
|
fmt.Sprintf("user = %q", v["user"]),
|
|
fmt.Sprintf("password = %q", v["password"]),
|
|
)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
},
|
|
},
|
|
{
|
|
key: "emby",
|
|
name: "Emby",
|
|
section: "emby",
|
|
intro: []string{
|
|
"Authenticate with an API key (Dashboard → API Keys)",
|
|
"or with your username and password.",
|
|
},
|
|
picker: &pickerSpec{
|
|
key: keyEmbyAuth,
|
|
label: "Authentication",
|
|
options: []pickerOption{
|
|
{value: "token", label: "API key"},
|
|
{value: "password", label: "Username + password"},
|
|
},
|
|
},
|
|
fields: []fieldSpec{
|
|
{key: "url", label: "Server URL", help: "e.g. https://emby.example.com", required: true},
|
|
{key: "token", label: "API key", required: true, secret: true,
|
|
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "token" }},
|
|
{key: "user", label: "Username (optional)", help: "multi-user servers: picks your account from /Users",
|
|
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "token" }},
|
|
{key: "user", label: "Username", required: true,
|
|
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "password" }},
|
|
{key: "password", label: "Password", required: true, secret: true,
|
|
onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "password" }},
|
|
},
|
|
validate: func(v map[string]string) error {
|
|
if err := emby.NewClient(v["url"], v["token"], "", v["user"], v["password"]).Ping(); err != nil {
|
|
return fmt.Errorf("emby: validation: %w", err)
|
|
}
|
|
return nil
|
|
},
|
|
body: func(v map[string]string) string {
|
|
lines := []string{fmt.Sprintf("url = %q", v["url"])}
|
|
if v[keyEmbyAuth] == "token" {
|
|
lines = append(lines, fmt.Sprintf("token = %q", v["token"]))
|
|
if v["user"] != "" {
|
|
lines = append(lines, fmt.Sprintf("user = %q", v["user"]))
|
|
}
|
|
} else {
|
|
lines = append(lines,
|
|
fmt.Sprintf("user = %q", v["user"]),
|
|
fmt.Sprintf("password = %q", v["password"]),
|
|
)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
},
|
|
},
|
|
{
|
|
key: "spotify",
|
|
name: "Spotify (Premium)",
|
|
section: "spotify",
|
|
intro: []string{
|
|
"Requires a Spotify Premium account.",
|
|
"",
|
|
"Recommended: register your own Spotify Developer app at",
|
|
"developer.spotify.com/dashboard (redirect URI",
|
|
"http://127.0.0.1:19872/login). Your own client_id gives you a",
|
|
"private rate-limit quota and works for playback, library, and",
|
|
"playlists. Apps registered after Nov 27, 2024 can't use",
|
|
"/v1/search though — that's a Spotify dev-mode restriction.",
|
|
"",
|
|
"Alternative: cliamp ships a built-in client_id (the librespot",
|
|
"keymaster) that bypasses the search restriction. It's shared",
|
|
"with every librespot- and spotify-player-based client, so you",
|
|
"may see occasional 429 errors when the pool is busy.",
|
|
},
|
|
picker: &pickerSpec{
|
|
key: keySpotifyMode,
|
|
label: "Client ID",
|
|
options: []pickerOption{
|
|
{value: "custom", label: "Use my own Spotify Developer app client_id"},
|
|
{value: "default", label: "Use built-in shared client_id"},
|
|
},
|
|
},
|
|
fields: []fieldSpec{
|
|
{key: "client_id", label: "Client ID", required: true,
|
|
help: "from developer.spotify.com/dashboard; redirect URI http://127.0.0.1:19872/login",
|
|
onlyIf: func(v map[string]string) bool { return v[keySpotifyMode] == "custom" }},
|
|
{key: "bitrate", label: "Bitrate", help: "96, 160, or 320 kbps", defaultV: "320"},
|
|
},
|
|
extraValidate: func(v map[string]string) error {
|
|
if v["bitrate"] == "" {
|
|
return nil
|
|
}
|
|
if _, err := strconv.Atoi(v["bitrate"]); err != nil {
|
|
return fmt.Errorf("bitrate must be a number")
|
|
}
|
|
return nil
|
|
},
|
|
body: func(v map[string]string) string {
|
|
br := v["bitrate"]
|
|
if br == "" {
|
|
br = "320"
|
|
}
|
|
lines := []string{}
|
|
if v[keySpotifyMode] == "custom" && v["client_id"] != "" {
|
|
lines = append(lines, fmt.Sprintf("client_id = %q", v["client_id"]))
|
|
}
|
|
lines = append(lines, fmt.Sprintf("bitrate = %s", br))
|
|
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",
|
|
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",
|
|
section: "ytmusic",
|
|
intro: []string{
|
|
"Works out of the box with built-in fallback credentials.",
|
|
"Provide your own OAuth client to skip the shared pool, and/or",
|
|
"a browser name for cookie-based age-gated playback.",
|
|
},
|
|
picker: &pickerSpec{
|
|
key: keyYTMusicMode,
|
|
label: "Mode",
|
|
options: []pickerOption{
|
|
{value: "default", label: "Use built-in credentials (recommended)"},
|
|
{value: "custom", label: "Provide my own OAuth credentials / cookies"},
|
|
{value: "off", label: "Disable YouTube Music"},
|
|
},
|
|
},
|
|
fields: []fieldSpec{
|
|
{key: "client_id", label: "OAuth Client ID",
|
|
onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }},
|
|
{key: "client_secret", label: "OAuth Client Secret", secret: true,
|
|
onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }},
|
|
{key: "cookies_from", label: "Cookies from browser", help: "e.g. chrome, firefox; blank to skip",
|
|
onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }},
|
|
},
|
|
body: func(v map[string]string) string {
|
|
switch v[keyYTMusicMode] {
|
|
case "off":
|
|
return "enabled = false"
|
|
case "custom":
|
|
lines := []string{"enabled = true"}
|
|
if v["client_id"] != "" {
|
|
lines = append(lines, fmt.Sprintf("client_id = %q", v["client_id"]))
|
|
}
|
|
if v["client_secret"] != "" {
|
|
lines = append(lines, fmt.Sprintf("client_secret = %q", v["client_secret"]))
|
|
}
|
|
if v["cookies_from"] != "" {
|
|
lines = append(lines, fmt.Sprintf("cookies_from = %q", v["cookies_from"]))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
default:
|
|
return "enabled = true"
|
|
}
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
const (
|
|
stageMenu stage = iota
|
|
stagePicker
|
|
stageForm
|
|
stageValidating
|
|
stageResult
|
|
)
|
|
|
|
type setupModel struct {
|
|
provs []providerSpec
|
|
w, h int
|
|
|
|
stage stage
|
|
menuCursor int
|
|
|
|
// active provider (index into provs); -1 means none
|
|
pidx int
|
|
|
|
// picker state (within a provider)
|
|
pickerCursor int
|
|
|
|
// form state
|
|
values map[string]string
|
|
visible []int // indices into provs[pidx].fields that are currently visible
|
|
fcursor int
|
|
|
|
// validating
|
|
spinFrame int
|
|
|
|
// result state
|
|
resultErr error
|
|
resultWarning bool // validation failed but config still valid
|
|
resultText string // overall message (e.g. "Saved [navidrome] section.")
|
|
saveFailed error // io error writing config (rare; shown as error result)
|
|
awaitingSave bool // true after a failed validate: waiting for y/n
|
|
|
|
// cached so we don't hit os.UserHomeDir per-frame
|
|
cfgPath string
|
|
}
|
|
|
|
func newSetupModel() *setupModel {
|
|
cfgPath, _ := configFilePath()
|
|
return &setupModel{
|
|
provs: providers(),
|
|
stage: stageMenu,
|
|
pidx: -1,
|
|
values: map[string]string{},
|
|
cfgPath: cfgPath,
|
|
}
|
|
}
|
|
|
|
func (m *setupModel) Init() tea.Cmd {
|
|
return func() tea.Msg { return tea.RequestWindowSize() }
|
|
}
|
|
|
|
// ----- Messages -----------------------------------------------------------
|
|
|
|
type validateDoneMsg struct{ err error }
|
|
|
|
type spinTickMsg struct{}
|
|
|
|
func spinTickCmd() tea.Cmd {
|
|
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return spinTickMsg{} })
|
|
}
|
|
|
|
func runValidateCmd(spec providerSpec, values map[string]string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if spec.validate == nil {
|
|
return validateDoneMsg{}
|
|
}
|
|
return validateDoneMsg{err: spec.validate(values)}
|
|
}
|
|
}
|
|
|
|
// ----- Update -------------------------------------------------------------
|
|
|
|
func (m *setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.w, m.h = msg.Width, msg.Height
|
|
return m, nil
|
|
|
|
case tea.KeyPressMsg:
|
|
return m.handleKey(msg)
|
|
|
|
case tea.PasteMsg:
|
|
return m.handlePaste(msg.Content)
|
|
|
|
case spinTickMsg:
|
|
if m.stage == stageValidating {
|
|
m.spinFrame++
|
|
return m, spinTickCmd()
|
|
}
|
|
return m, nil
|
|
|
|
case validateDoneMsg:
|
|
return m.onValidateDone(msg.err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// handlePaste appends bracketed-paste content into the active form field.
|
|
// Newlines and other control characters are stripped — the wizard's fields
|
|
// are all single-line, and pasted credentials often have a trailing newline
|
|
// from the source app.
|
|
func (m *setupModel) handlePaste(content string) (tea.Model, tea.Cmd) {
|
|
if content == "" || m.stage != stageForm || len(m.visible) == 0 {
|
|
return m, nil
|
|
}
|
|
clean := sanitizePaste(content)
|
|
if clean == "" {
|
|
return m, nil
|
|
}
|
|
field := m.provs[m.pidx].fields[m.visible[m.fcursor]]
|
|
m.values[field.key] += clean
|
|
return m, nil
|
|
}
|
|
|
|
func sanitizePaste(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for _, r := range s {
|
|
// Drop newlines and other ASCII control chars; keep spaces and tabs (tabs as space).
|
|
switch {
|
|
case r == '\n' || r == '\r':
|
|
continue
|
|
case r == '\t':
|
|
b.WriteRune(' ')
|
|
case r < 0x20:
|
|
continue
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m *setupModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
if msg.String() == "ctrl+c" {
|
|
return m, tea.Quit
|
|
}
|
|
|
|
switch m.stage {
|
|
case stageMenu:
|
|
return m.menuKey(msg)
|
|
case stagePicker:
|
|
return m.pickerKey(msg)
|
|
case stageForm:
|
|
return m.formKey(msg)
|
|
case stageValidating:
|
|
// Ignore input while probing.
|
|
return m, nil
|
|
case stageResult:
|
|
return m.resultKey(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *setupModel) menuKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "q", "esc":
|
|
return m, tea.Quit
|
|
case "up", "k":
|
|
if m.menuCursor > 0 {
|
|
m.menuCursor--
|
|
}
|
|
case "down", "j":
|
|
if m.menuCursor < len(m.provs)-1 {
|
|
m.menuCursor++
|
|
}
|
|
case "enter":
|
|
m.startProvider(m.menuCursor)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *setupModel) startProvider(idx int) {
|
|
m.pidx = idx
|
|
m.values = map[string]string{}
|
|
m.fcursor = 0
|
|
m.pickerCursor = 0
|
|
m.resultErr = nil
|
|
m.resultText = ""
|
|
m.resultWarning = false
|
|
m.awaitingSave = false
|
|
m.saveFailed = nil
|
|
|
|
if m.provs[idx].picker != nil {
|
|
m.stage = stagePicker
|
|
} else {
|
|
m.refreshVisibleFields()
|
|
m.stage = stageForm
|
|
}
|
|
}
|
|
|
|
func (m *setupModel) pickerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
p := m.provs[m.pidx].picker
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.stage = stageMenu
|
|
return m, nil
|
|
case "up", "k":
|
|
if m.pickerCursor > 0 {
|
|
m.pickerCursor--
|
|
}
|
|
case "down", "j":
|
|
if m.pickerCursor < len(p.options)-1 {
|
|
m.pickerCursor++
|
|
}
|
|
case "enter":
|
|
m.values[p.key] = p.options[m.pickerCursor].value
|
|
// Pickers can short-circuit with no fields (e.g. ytmusic "default" / "off").
|
|
m.refreshVisibleFields()
|
|
if len(m.visible) == 0 {
|
|
return m.submitForm()
|
|
}
|
|
m.stage = stageForm
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *setupModel) formKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
if len(m.visible) == 0 {
|
|
// Defensive: shouldn't happen; submit anyway.
|
|
return m.submitForm()
|
|
}
|
|
field := m.provs[m.pidx].fields[m.visible[m.fcursor]]
|
|
cur := m.values[field.key]
|
|
|
|
switch msg.Code {
|
|
case tea.KeyEscape:
|
|
m.stage = stageMenu
|
|
return m, nil
|
|
case tea.KeyUp:
|
|
if m.fcursor > 0 {
|
|
m.fcursor--
|
|
}
|
|
return m, nil
|
|
case tea.KeyTab, tea.KeyDown:
|
|
// Shift+Tab arrives with ModShift; treat it as "previous field".
|
|
if msg.Mod&tea.ModShift != 0 {
|
|
if m.fcursor > 0 {
|
|
m.fcursor--
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.fcursor < len(m.visible)-1 {
|
|
m.fcursor++
|
|
}
|
|
return m, nil
|
|
case tea.KeyEnter:
|
|
// Enter on the last field submits; otherwise advance.
|
|
if m.fcursor < len(m.visible)-1 {
|
|
m.fcursor++
|
|
return m, nil
|
|
}
|
|
return m.submitForm()
|
|
case tea.KeyBackspace:
|
|
if cur != "" {
|
|
m.values[field.key] = removeLastRune(cur)
|
|
}
|
|
return m, nil
|
|
case tea.KeySpace:
|
|
m.values[field.key] = cur + " "
|
|
return m, nil
|
|
}
|
|
|
|
// Treat ctrl+s / ctrl+d as submit shortcuts.
|
|
if s := msg.String(); s == "ctrl+s" || s == "ctrl+d" {
|
|
return m.submitForm()
|
|
}
|
|
|
|
if len(msg.Text) > 0 {
|
|
m.values[field.key] = cur + msg.Text
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *setupModel) submitForm() (tea.Model, tea.Cmd) {
|
|
spec := m.provs[m.pidx]
|
|
|
|
// Apply defaults for blank fields.
|
|
for _, f := range spec.fields {
|
|
if m.values[f.key] == "" && f.defaultV != "" {
|
|
m.values[f.key] = f.defaultV
|
|
}
|
|
}
|
|
|
|
// Required-field check (only for visible fields).
|
|
for i, idx := range m.visible {
|
|
f := spec.fields[idx]
|
|
if f.required && strings.TrimSpace(m.values[f.key]) == "" {
|
|
m.fcursor = i
|
|
m.resultErr = fmt.Errorf("%s is required", f.label)
|
|
m.resultText = ""
|
|
m.stage = stageResult
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
if spec.extraValidate != nil {
|
|
if err := spec.extraValidate(m.values); err != nil {
|
|
m.resultErr = err
|
|
m.stage = stageResult
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
// Light URL sanity check before any network call.
|
|
if u, ok := m.values["url"]; ok && u != "" {
|
|
clean := strings.TrimRight(u, "/")
|
|
if !looksLikeHTTPURL(clean) {
|
|
m.resultErr = fmt.Errorf("URL must start with http:// or https://")
|
|
m.stage = stageResult
|
|
return m, nil
|
|
}
|
|
m.values["url"] = clean
|
|
}
|
|
|
|
if spec.validate == nil {
|
|
// No probe; save immediately.
|
|
return m, m.persistAndDone(false)
|
|
}
|
|
|
|
m.stage = stageValidating
|
|
m.spinFrame = 0
|
|
return m, tea.Batch(spinTickCmd(), runValidateCmd(spec, m.values))
|
|
}
|
|
|
|
func (m *setupModel) onValidateDone(err error) (tea.Model, tea.Cmd) {
|
|
if err == nil {
|
|
return m, m.persistAndDone(false)
|
|
}
|
|
m.resultErr = err
|
|
m.awaitingSave = true
|
|
m.stage = stageResult
|
|
return m, nil
|
|
}
|
|
|
|
// persistAndDone writes the section and transitions to result. warn=true
|
|
// indicates the user opted to save despite a failed probe.
|
|
func (m *setupModel) persistAndDone(warn bool) tea.Cmd {
|
|
spec := m.provs[m.pidx]
|
|
body := spec.body(m.values)
|
|
if err := saveSection(spec.section, body); err != nil {
|
|
m.saveFailed = err
|
|
m.stage = stageResult
|
|
m.awaitingSave = false
|
|
return nil
|
|
}
|
|
m.stage = stageResult
|
|
m.awaitingSave = false
|
|
m.resultWarning = warn
|
|
m.resultText = fmt.Sprintf("Saved [%s] section.", spec.section)
|
|
return nil
|
|
}
|
|
|
|
func (m *setupModel) resultKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
if m.awaitingSave {
|
|
switch strings.ToLower(msg.String()) {
|
|
case "y":
|
|
m.persistAndDone(true)
|
|
return m, nil
|
|
case "n", "esc":
|
|
m.awaitingSave = false
|
|
m.resultErr = nil
|
|
m.stage = stageMenu
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "enter", "esc", "q", " ":
|
|
m.stage = stageMenu
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// refreshVisibleFields recomputes the visible-field index list based on
|
|
// the current values map, then resets the cursor to a sensible position.
|
|
func (m *setupModel) refreshVisibleFields() {
|
|
spec := m.provs[m.pidx]
|
|
m.visible = m.visible[:0]
|
|
for i, f := range spec.fields {
|
|
if f.onlyIf == nil || f.onlyIf(m.values) {
|
|
m.visible = append(m.visible, i)
|
|
}
|
|
}
|
|
if m.fcursor >= len(m.visible) {
|
|
m.fcursor = 0
|
|
}
|
|
}
|
|
|
|
// ----- Rendering ----------------------------------------------------------
|
|
|
|
var (
|
|
// Color choices match the existing player palette; ANSI numbers give
|
|
// good contrast against any terminal theme.
|
|
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(10)).Bold(true)
|
|
hintStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(7))
|
|
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(8))
|
|
accentStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(11)).Bold(true)
|
|
errStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(9)).Bold(true)
|
|
okStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(10)).Bold(true)
|
|
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(11)).Bold(true)
|
|
cardStyle = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.ANSIColor(8)).
|
|
Padding(1, 2)
|
|
activeFieldStyle = lipgloss.NewStyle().
|
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
|
BorderForeground(lipgloss.ANSIColor(11)).
|
|
PaddingLeft(1)
|
|
inactiveFieldStyle = lipgloss.NewStyle().PaddingLeft(2)
|
|
)
|
|
|
|
const (
|
|
maxCardWidth = 78
|
|
logoLine1 = " cliamp setup"
|
|
logoLine2 = " configure remote providers"
|
|
)
|
|
|
|
func (m *setupModel) View() tea.View {
|
|
header := titleStyle.Render(logoLine1) + "\n" +
|
|
hintStyle.Render(logoLine2) + "\n"
|
|
|
|
var body string
|
|
switch m.stage {
|
|
case stageMenu:
|
|
body = m.viewMenu()
|
|
case stagePicker:
|
|
body = m.viewPicker()
|
|
case stageForm:
|
|
body = m.viewForm()
|
|
case stageValidating:
|
|
body = m.viewValidating()
|
|
case stageResult:
|
|
body = m.viewResult()
|
|
}
|
|
|
|
footer := "\n" + m.viewFooter()
|
|
view := tea.NewView(header + "\n" + body + footer)
|
|
view.AltScreen = true
|
|
return view
|
|
}
|
|
|
|
// card wraps body in the standard rounded-border card sized to the terminal.
|
|
func (m *setupModel) card(body string) string {
|
|
return cardStyle.Width(min(maxCardWidth, m.viewWidth())).Render(body)
|
|
}
|
|
|
|
func (m *setupModel) viewMenu() string {
|
|
var b strings.Builder
|
|
b.WriteString(accentStyle.Render("Pick a provider to configure"))
|
|
b.WriteString("\n\n")
|
|
for i, p := range m.provs {
|
|
marker := " "
|
|
nameRender := p.name
|
|
if i == m.menuCursor {
|
|
marker = accentStyle.Render("▸ ")
|
|
nameRender = accentStyle.Render(p.name)
|
|
}
|
|
b.WriteString(marker)
|
|
b.WriteString(nameRender)
|
|
b.WriteString("\n")
|
|
// Render the first intro line as a one-liner per item, faintly.
|
|
if len(p.intro) > 0 {
|
|
indent := " "
|
|
b.WriteString(indent)
|
|
b.WriteString(hintStyle.Render(p.intro[0]))
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(dimStyle.Render("config: " + m.cfgPath))
|
|
return m.card(b.String())
|
|
}
|
|
|
|
func (m *setupModel) viewPicker() string {
|
|
spec := m.provs[m.pidx]
|
|
p := spec.picker
|
|
var b strings.Builder
|
|
b.WriteString(accentStyle.Render(spec.name))
|
|
b.WriteString("\n\n")
|
|
for _, line := range spec.intro {
|
|
b.WriteString(hintStyle.Render(line))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(accentStyle.Render(p.label))
|
|
b.WriteString("\n\n")
|
|
for i, opt := range p.options {
|
|
marker := " "
|
|
text := opt.label
|
|
if i == m.pickerCursor {
|
|
marker = accentStyle.Render("▸ ")
|
|
text = accentStyle.Render(opt.label)
|
|
}
|
|
b.WriteString(marker)
|
|
b.WriteString(text)
|
|
b.WriteString("\n")
|
|
}
|
|
return m.card(b.String())
|
|
}
|
|
|
|
func (m *setupModel) viewForm() string {
|
|
spec := m.provs[m.pidx]
|
|
var b strings.Builder
|
|
b.WriteString(accentStyle.Render(spec.name))
|
|
b.WriteString("\n\n")
|
|
for _, line := range spec.intro {
|
|
b.WriteString(hintStyle.Render(line))
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
for i, idx := range m.visible {
|
|
f := spec.fields[idx]
|
|
val := m.values[f.key]
|
|
display := val
|
|
if f.secret {
|
|
display = strings.Repeat("•", utf8.RuneCountInString(val))
|
|
}
|
|
labelLine := f.label
|
|
if f.required {
|
|
labelLine += accentStyle.Render(" *")
|
|
}
|
|
valueLine := display
|
|
if i == m.fcursor {
|
|
// Active: show cursor caret at end of value.
|
|
valueLine = display + accentStyle.Render("▎")
|
|
block := labelLine + "\n" + valueLine
|
|
if f.help != "" {
|
|
block += "\n" + dimStyle.Render(f.help)
|
|
}
|
|
b.WriteString(activeFieldStyle.Render(block))
|
|
} else {
|
|
if valueLine == "" {
|
|
valueLine = dimStyle.Render("(empty)")
|
|
}
|
|
block := labelLine + "\n" + valueLine
|
|
b.WriteString(inactiveFieldStyle.Render(block))
|
|
}
|
|
b.WriteString("\n\n")
|
|
}
|
|
return m.card(strings.TrimRight(b.String(), "\n"))
|
|
}
|
|
|
|
func (m *setupModel) viewValidating() string {
|
|
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
|
frame := frames[m.spinFrame%len(frames)]
|
|
body := accentStyle.Render(frame) + " " +
|
|
"Verifying connection to " + m.provs[m.pidx].name + "…"
|
|
return cardStyle.Width(min(maxCardWidth, m.viewWidth())).Render(body)
|
|
}
|
|
|
|
func (m *setupModel) viewResult() string {
|
|
spec := m.provs[m.pidx]
|
|
var b strings.Builder
|
|
b.WriteString(accentStyle.Render(spec.name))
|
|
b.WriteString("\n\n")
|
|
|
|
if m.saveFailed != nil {
|
|
b.WriteString(errStyle.Render("✗ Failed to write config"))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(m.saveFailed.Error())
|
|
b.WriteString("\n\n")
|
|
b.WriteString(hintStyle.Render("Press any key to return to the menu."))
|
|
return m.card(b.String())
|
|
}
|
|
|
|
if m.awaitingSave {
|
|
b.WriteString(errStyle.Render("✗ Validation failed"))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(m.resultErr.Error())
|
|
b.WriteString("\n\n")
|
|
b.WriteString(hintStyle.Render("The config will still load on next launch — useful when the server is offline now."))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(accentStyle.Render("Save anyway? ") + "[y/N]")
|
|
return m.card(b.String())
|
|
}
|
|
|
|
if m.resultErr != nil {
|
|
b.WriteString(errStyle.Render("✗ "))
|
|
b.WriteString(m.resultErr.Error())
|
|
b.WriteString("\n\n")
|
|
b.WriteString(hintStyle.Render("Press any key to return to the menu."))
|
|
return m.card(b.String())
|
|
}
|
|
|
|
if m.resultWarning {
|
|
b.WriteString(warnStyle.Render("⚠ Saved without verification"))
|
|
} else {
|
|
b.WriteString(okStyle.Render("✓ " + m.resultText))
|
|
}
|
|
b.WriteString("\n\n")
|
|
b.WriteString(dimStyle.Render(m.cfgPath))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(hintStyle.Render("Press any key to configure another provider, or q to quit."))
|
|
return m.card(b.String())
|
|
}
|
|
|
|
func (m *setupModel) viewFooter() string {
|
|
var keys string
|
|
switch m.stage {
|
|
case stageMenu:
|
|
keys = "↑/↓ pick enter select q quit"
|
|
case stagePicker:
|
|
keys = "↑/↓ pick enter confirm esc back"
|
|
case stageForm:
|
|
keys = "↑/↓ field enter submit esc back"
|
|
case stageValidating:
|
|
keys = "ctrl+c cancel"
|
|
case stageResult:
|
|
if m.awaitingSave {
|
|
keys = "y save anyway n cancel"
|
|
} else {
|
|
keys = "any key continue q quit"
|
|
}
|
|
}
|
|
return dimStyle.Render(keys)
|
|
}
|
|
|
|
func (m *setupModel) viewWidth() int {
|
|
if m.w <= 4 {
|
|
return maxCardWidth
|
|
}
|
|
return m.w - 2
|
|
}
|
|
|
|
// ----- Helpers ------------------------------------------------------------
|
|
|
|
func looksLikeHTTPURL(s string) bool {
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return u.Scheme == "http" || u.Scheme == "https"
|
|
}
|
|
|
|
func configFilePath() (string, error) {
|
|
dir, err := appdir.Dir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, "config.toml"), nil
|
|
}
|
|
|
|
// removeLastRune trims the final UTF-8 rune from s.
|
|
func removeLastRune(s string) string {
|
|
if len(s) > 0 {
|
|
_, size := utf8.DecodeLastRuneInString(s)
|
|
return s[:len(s)-size]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// saveSection rewrites or appends a [section] block in config.toml. The
|
|
// body is the raw TOML between the header and the next section/EOF; it
|
|
// must not contain a header line itself. Existing content for the same
|
|
// section is replaced; everything else is preserved as-is.
|
|
func saveSection(section, body string) error {
|
|
path, err := configFilePath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
header := "[" + section + "]"
|
|
block := header + "\n" + strings.TrimRight(body, "\n") + "\n"
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, []byte(block), 0o644)
|
|
}
|
|
|
|
lines := strings.Split(string(data), "\n")
|
|
start, end := findSection(lines, section)
|
|
if start < 0 {
|
|
out := strings.TrimRight(string(data), "\n")
|
|
if out != "" {
|
|
out += "\n\n"
|
|
}
|
|
out += block
|
|
return os.WriteFile(path, []byte(out), 0o644)
|
|
}
|
|
|
|
before := strings.TrimRight(strings.Join(lines[:start], "\n"), "\n")
|
|
after := ""
|
|
if end < len(lines) {
|
|
after = strings.TrimLeft(strings.Join(lines[end:], "\n"), "\n")
|
|
}
|
|
var b strings.Builder
|
|
if before != "" {
|
|
b.WriteString(before)
|
|
b.WriteString("\n\n")
|
|
}
|
|
b.WriteString(block)
|
|
if after != "" {
|
|
b.WriteString("\n")
|
|
b.WriteString(after)
|
|
if !strings.HasSuffix(after, "\n") {
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
return os.WriteFile(path, []byte(b.String()), 0o644)
|
|
}
|
|
|
|
// findSection returns [start, end) line indices for the [name] block in
|
|
// lines, where start points at the header line and end points at the
|
|
// next section header (or len(lines) if it's the last block). Returns
|
|
// (-1, -1) if the section is absent.
|
|
func findSection(lines []string, name string) (int, int) {
|
|
target := "[" + strings.ToLower(name) + "]"
|
|
start := -1
|
|
for i, l := range lines {
|
|
t := strings.ToLower(strings.TrimSpace(l))
|
|
if t == target {
|
|
start = i
|
|
break
|
|
}
|
|
}
|
|
if start < 0 {
|
|
return -1, -1
|
|
}
|
|
for i := start + 1; i < len(lines); i++ {
|
|
t := strings.TrimSpace(lines[i])
|
|
if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
|
|
return start, i
|
|
}
|
|
}
|
|
return start, len(lines)
|
|
}
|