项目文件夹

文件
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

850 行
27 KiB
Go

// Package config handles loading user configuration from ~/.config/cliamp/config.toml.
package config
import (
"bufio"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/fileutil"
)
// configPath returns the path to the config file.
func configPath() (string, error) {
dir, err := appdir.Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, "config.toml"), nil
}
// parseString trims surrounding quotes from a TOML string value and, if the
// result is exactly $NAME or ${NAME}, replaces it with the value of that
// environment variable (or "" when unset). Mixed values containing other
// characters are left untouched, so literal '$' in passwords is preserved.
func parseString(s string) string {
s = strings.Trim(s, `"'`)
if len(s) < 2 || s[0] != '$' {
return s
}
name := s[1:]
if name[0] == '{' {
if name[len(name)-1] != '}' {
return s
}
name = name[1 : len(name)-1]
}
if !isEnvName(name) {
return s
}
return os.Getenv(name)
}
func isEnvName(s string) bool {
if s == "" {
return false
}
for i, r := range s {
switch {
case r == '_':
case r >= 'A' && r <= 'Z':
case r >= 'a' && r <= 'z':
case i > 0 && r >= '0' && r <= '9':
default:
return false
}
}
return true
}
// NavidromeConfig holds credentials for a Navidrome/Subsonic server.
// All three fields must be non-empty for a client to be constructed.
type NavidromeConfig struct {
URL string // e.g. "https://music.example.com"
User string
Password string
BrowseSort string // album browse sort order, e.g. "alphabeticalByName"
ScrobbleDisabled bool // true only when "scrobble = false" is explicitly set
}
// IsSet reports whether all three Navidrome credentials are present.
func (n NavidromeConfig) IsSet() bool {
return n.URL != "" && n.User != "" && n.Password != ""
}
// SpotifyConfig holds settings for the Spotify provider. Requires a Spotify
// Premium account. If client_id is empty, a built-in fallback (the librespot
// keymaster ID) is used so search and catalog endpoints work even for users
// who never registered their own developer app — see Spotify's Nov 27, 2024
// dev-mode quota restriction.
type SpotifyConfig struct {
Disabled bool // true only when user explicitly sets enabled = false
Enabled bool // true when [spotify] section exists (even without client_id)
ClientID string // Spotify Developer app client ID (overrides built-in fallback)
Bitrate int // preferred Spotify stream bitrate in kbps
}
// IsSet reports whether the Spotify provider should be shown. Section presence
// is enough — a built-in fallback client_id is used when none is configured.
func (s SpotifyConfig) IsSet() bool {
return !s.Disabled && s.Enabled
}
// ResolveClientID returns the user's configured client_id, or fallbackID when
// none is set.
func (s SpotifyConfig) ResolveClientID(fallbackID string) string {
if s.ClientID != "" {
return s.ClientID
}
return fallbackID
}
// QobuzConfig holds settings for the Qobuz provider. Requires a paid Qobuz
// subscription (Studio/Sublime). The app_id, signing secrets and OAuth private
// key are scraped automatically from the Qobuz web player, so no developer
// credentials are needed. Sign-in is an interactive OAuth browser flow.
type QobuzConfig struct {
Disabled bool // true only when user explicitly sets enabled = false
Enabled bool // true when [qobuz] section exists
Quality int // preferred stream format_id: 5 (MP3 320), 6 (FLAC CD), 7 (Hi-Res <=96kHz), 27 (Hi-Res <=192kHz)
}
// IsSet reports whether the Qobuz provider should be shown. Section presence
// is enough; credentials are scraped from the Qobuz web player automatically.
func (q QobuzConfig) IsSet() bool {
return !q.Disabled && q.Enabled
}
// YouTubeMusicConfig holds settings for the YouTube Music provider.
// If no client_id/client_secret are set, built-in fallback credentials are
// used automatically (same pattern as Spotify).
type YouTubeMusicConfig struct {
Disabled bool // true only when user explicitly sets enabled = false
Enabled bool // true when [ytmusic] section exists (even without credentials)
ClientID string // Google Cloud OAuth2 client ID (overrides built-in fallback)
ClientSecret string // Google Cloud OAuth2 client secret (overrides built-in fallback)
CookiesFrom string // browser name for yt-dlp --cookies-from-browser (e.g. "chrome", "firefox")
}
// IsSetOrFallback returns true when YouTube providers should be enabled,
// either via config or because fallback credentials are available.
func (y YouTubeMusicConfig) IsSetOrFallback(fallbackFn func() (string, string)) bool {
if y.Disabled {
return false
}
if y.Enabled {
return true
}
// Even without a config section, enable if fallback credentials exist.
if fallbackFn != nil {
id, secret := fallbackFn()
return id != "" && secret != ""
}
return false
}
// ResolveCredentials returns the user's configured credentials, or falls back
// to the built-in pool. Returns empty strings only when the pool is also empty.
func (y YouTubeMusicConfig) ResolveCredentials(fallbackFn func() (string, string)) (clientID, clientSecret string) {
if y.ClientID != "" && y.ClientSecret != "" {
return y.ClientID, y.ClientSecret
}
if fallbackFn != nil {
return fallbackFn()
}
return "", ""
}
// SoundCloudConfig holds settings for the SoundCloud provider.
// SoundCloud is opt-in: requires enabled = true in [soundcloud] before the
// provider registers. Setting User exposes that profile's Tracks/Likes/Reposts
// in the browse view. Setting CookiesFrom (browser name) lets yt-dlp use the
// user's signed-in session for subscriber-gated tracks.
type SoundCloudConfig struct {
Enabled bool // true only when user explicitly sets enabled = true
User string // SoundCloud username for browse (optional)
CookiesFrom string // browser name for yt-dlp --cookies-from-browser (optional)
}
// 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 {
URL string // e.g. "http://192.168.1.10:32400"
Token string // X-Plex-Token
Libraries []string // optional: restrict to these music library names
}
// IsSet reports whether both Plex credentials are present.
func (p PlexConfig) IsSet() bool {
return p.URL != "" && p.Token != ""
}
// JellyfinConfig holds credentials for a Jellyfin server.
// URL is required. Authenticate either with Token, or with User+Password.
// UserID is optional and can be discovered lazily.
type JellyfinConfig struct {
URL string // e.g. "https://jellyfin.example.com"
Token string // API access token
User string // optional username for password-based login
Password string // optional password for password-based login
UserID string // optional user id to skip discovery via /Users/Me
}
// IsSet reports whether the Jellyfin provider is configured.
func (j JellyfinConfig) IsSet() bool {
return j.URL != "" && (j.Token != "" || (j.User != "" && j.Password != ""))
}
// EmbyConfig holds credentials for an Emby server.
// URL is required. Authenticate either with Token, or with User+Password.
// UserID is optional and can be discovered lazily.
type EmbyConfig struct {
URL string // e.g. "https://emby.example.com"
Token string // API access token
User string // optional username for password-based login
Password string // optional password for password-based login
UserID string // optional user id to skip discovery via /Users/Me
}
// IsSet reports whether the Emby provider is configured.
func (e EmbyConfig) IsSet() bool {
return e.URL != "" && (e.Token != "" || (e.User != "" && e.Password != ""))
}
// Config holds user preferences loaded from the config file.
type Config struct {
Volume float64 // dB, clamped at runtime to [VolumeMin, +6]
VolumeMin float64 // dB floor, range [-90, 0]; default -50
VisVolumeLinked bool // when true, visualizer bar height follows volume; default true
EQ [10]float64 // per-band gain in dB, range [-12, +12]
EQPreset string // preset name, or "" for custom
Repeat string // "off", "all", or "one"
Shuffle bool
Mono bool
Speed float64 // playback speed ratio: 0.25–2.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", "qobuz", "plex", "jellyfin", "emby", "soundcloud", "netease", "ytmusic" (default "radio")
Theme string // theme name, or "" for ANSI default
Visualizer string // visualizer mode name, or "" for default (Bars)
SampleRate int // output sample rate: 22050, 44100, 48000, 96000, 192000
BufferMs int // speaker buffer in milliseconds (50–500)
ResampleQuality int // beep resample quality factor (1–4)
BitDepth int // PCM bit depth for FFmpeg output: 16 or 32
Compact bool // compact mode: cap frame width at 80 columns
PaddingH int // horizontal padding for the UI frame (default 3)
PaddingV int // vertical padding for the UI frame (default 1)
AudioDevice string // preferred audio output device name (empty = system default)
Playlist string // local TOML playlist name to load on startup
InitialDirectory string // initial directory for the file browser
Navidrome NavidromeConfig // optional Navidrome/Subsonic server credentials
Spotify SpotifyConfig // optional Spotify provider (requires Premium)
Qobuz QobuzConfig // optional Qobuz provider (requires subscription)
YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider
Plex PlexConfig // optional Plex Media Server credentials
Jellyfin JellyfinConfig // optional Jellyfin server credentials
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")
LowPower bool // reduce CPU by lowering UI cadence and disabling visualization
}
// defaultConfig returns a Config with sensible defaults.
// SampleRate defaults to 0, which means "auto-detect from the system's default
// output device" (see player.DeviceSampleRate). This ensures USB audio devices
// that require a specific rate (commonly 48 kHz) work out of the box.
func defaultConfig() Config {
return Config{
VolumeMin: -50,
VisVolumeLinked: true,
Repeat: "off",
AutoPlay: false,
Speed: 1.0,
SeekStepLarge: 30,
SampleRate: 0,
BufferMs: 100,
ResampleQuality: 4,
BitDepth: 16,
PaddingH: 3,
PaddingV: 1,
Spotify: SpotifyConfig{Bitrate: 320},
Qobuz: QobuzConfig{Quality: 6},
LogLevel: "info",
}
}
// Load reads the config file from ~/.config/cliamp/config.toml.
// Returns defaults if the file does not exist.
func Load() (Config, error) {
cfg := defaultConfig()
path, err := configPath()
if err != nil {
return cfg, nil
}
f, err := os.Open(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return cfg, nil
}
return cfg, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
section := "" // current [section] header, empty = top-level
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Section header: [navidrome], [plex], [plugins.lastfm], etc.
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section = strings.ToLower(line[1 : len(line)-1])
// Mark providers as enabled when their section exists.
// [yt], [youtube], and [ytmusic] all configure the same YouTube providers.
switch section {
case "yt", "youtube", "ytmusic":
cfg.YouTubeMusic.Enabled = true
section = "ytmusic" // normalize for key parsing below
case "spotify":
cfg.Spotify.Enabled = true
case "qobuz":
cfg.Qobuz.Enabled = true
}
// Initialize plugin sub-maps for [plugins] and [plugins.*] sections.
if section == "plugins" || strings.HasPrefix(section, "plugins.") {
if cfg.Plugins == nil {
cfg.Plugins = make(map[string]map[string]string)
}
pluginName := strings.TrimPrefix(section, "plugins.")
if pluginName == "plugins" {
pluginName = "" // top-level [plugins] section
}
if _, ok := cfg.Plugins[pluginName]; !ok {
cfg.Plugins[pluginName] = make(map[string]string)
}
}
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
switch section {
case "navidrome":
switch key {
case "url":
cfg.Navidrome.URL = parseString(val)
case "user":
cfg.Navidrome.User = parseString(val)
case "password":
cfg.Navidrome.Password = parseString(val)
case "browse_sort":
cfg.Navidrome.BrowseSort = parseString(val)
case "scrobble":
// Opt-out: only mark disabled when the value is explicitly "false".
cfg.Navidrome.ScrobbleDisabled = strings.ToLower(val) == "false"
}
case "spotify":
switch key {
case "enabled":
cfg.Spotify.Disabled = strings.ToLower(val) == "false"
case "client_id":
cfg.Spotify.ClientID = parseString(val)
case "bitrate":
if v, err := strconv.Atoi(val); err == nil {
cfg.Spotify.Bitrate = v
}
}
case "qobuz":
switch key {
case "enabled":
cfg.Qobuz.Disabled = strings.ToLower(val) == "false"
case "quality":
if v, err := strconv.Atoi(val); err == nil {
cfg.Qobuz.Quality = v
}
}
case "ytmusic":
switch key {
case "enabled":
cfg.YouTubeMusic.Disabled = strings.ToLower(val) == "false"
case "client_id":
cfg.YouTubeMusic.ClientID = parseString(val)
case "client_secret":
cfg.YouTubeMusic.ClientSecret = parseString(val)
case "cookies_from":
cfg.YouTubeMusic.CookiesFrom = parseString(val)
}
case "plex":
switch key {
case "url":
cfg.Plex.URL = parseString(val)
case "token":
cfg.Plex.Token = parseString(val)
case "libraries":
cfg.Plex.Libraries = parseStringSlice(val)
}
case "soundcloud":
switch key {
case "enabled":
cfg.SoundCloud.Enabled = strings.ToLower(val) == "true"
case "user":
cfg.SoundCloud.User = parseString(val)
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":
cfg.Jellyfin.URL = parseString(val)
case "token":
cfg.Jellyfin.Token = parseString(val)
case "user":
cfg.Jellyfin.User = parseString(val)
case "password":
cfg.Jellyfin.Password = parseString(val)
case "user_id":
cfg.Jellyfin.UserID = parseString(val)
}
case "emby":
switch key {
case "url":
cfg.Emby.URL = parseString(val)
case "token":
cfg.Emby.Token = parseString(val)
case "user":
cfg.Emby.User = parseString(val)
case "password":
cfg.Emby.Password = parseString(val)
case "user_id":
cfg.Emby.UserID = parseString(val)
}
default:
// Handle [plugins] and [plugins.*] sections.
if section == "plugins" || strings.HasPrefix(section, "plugins.") {
pluginName := strings.TrimPrefix(section, "plugins.")
if pluginName == "plugins" {
pluginName = "" // top-level [plugins] section
}
if cfg.Plugins != nil {
if m, ok := cfg.Plugins[pluginName]; ok {
m[key] = parseString(val)
}
}
continue
}
switch key {
case "volume":
if v, err := strconv.ParseFloat(val, 64); err == nil {
cfg.Volume = v
}
case "volume_min":
if v, err := strconv.ParseFloat(val, 64); err == nil {
cfg.VolumeMin = v
}
case "vis_volume_linked":
if v, err := strconv.ParseBool(val); err == nil {
cfg.VisVolumeLinked = v
}
case "repeat":
val = parseString(val)
switch strings.ToLower(val) {
case "all", "one", "off":
cfg.Repeat = strings.ToLower(val)
}
case "shuffle":
cfg.Shuffle = val == "true"
case "mono":
cfg.Mono = val == "true"
case "auto_play":
cfg.AutoPlay = val == "true"
case "seek_large_step_sec":
if v, err := strconv.Atoi(val); err == nil {
cfg.SeekStepLarge = v
}
case "eq":
cfg.EQ = parseEQ(val)
case "eq_preset":
cfg.EQPreset = parseString(val)
case "theme":
cfg.Theme = parseString(val)
case "provider":
cfg.Provider = strings.ToLower(parseString(val))
case "visualizer":
cfg.Visualizer = parseString(val)
case "sample_rate":
if v, err := strconv.Atoi(val); err == nil {
cfg.SampleRate = v
}
case "buffer_ms":
if v, err := strconv.Atoi(val); err == nil {
cfg.BufferMs = v
}
case "resample_quality":
if v, err := strconv.Atoi(val); err == nil {
cfg.ResampleQuality = v
}
case "bit_depth":
if v, err := strconv.Atoi(val); err == nil {
cfg.BitDepth = v
}
case "speed":
if v, err := strconv.ParseFloat(val, 64); err == nil {
cfg.Speed = v
}
case "compact":
cfg.Compact = val == "true"
case "audio_device":
cfg.AudioDevice = parseString(val)
case "initial_directory":
cfg.InitialDirectory = parseString(val)
case "padding_horizontal":
if v, err := strconv.Atoi(val); err == nil {
cfg.PaddingH = v
}
case "padding_vertical":
if v, err := strconv.Atoi(val); err == nil {
cfg.PaddingV = v
}
case "log_level":
lvl := strings.ToLower(parseString(val))
switch lvl {
case "debug", "info", "warn", "warning", "error":
cfg.LogLevel = lvl
}
case "low_power":
cfg.LowPower = strings.ToLower(val) == "true"
}
}
}
cfg.clamp()
return cfg, scanner.Err()
}
// Save updates only the given key in the existing config file, preserving
// all other content, comments, and formatting. If the key doesn't exist,
// it is appended. If no config file exists, one is created with just that key.
func Save(key, value string) error {
path, err := configPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
line := fmt.Sprintf("%s = %s", key, value)
data, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return err
}
return fileutil.WriteFileAtomic(path, []byte(line+"\n"), 0o600)
}
// Scan existing lines and replace the matching key in-place,
// but only in the top-level scope (before any [section] header).
lines := strings.Split(string(data), "\n")
found := false
for i, l := range lines {
trimmed := strings.TrimSpace(l)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
// Stop searching once we hit a section header — the key
// belongs in the top-level scope only.
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
break
}
k, _, ok := strings.Cut(trimmed, "=")
if ok && strings.TrimSpace(k) == key {
lines[i] = line
found = true
break
}
}
if !found {
// Insert before the first section header to keep top-level keys together.
inserted := false
for i, l := range lines {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
lines = append(lines[:i], append([]string{line}, lines[i:]...)...)
inserted = true
break
}
}
if !inserted {
lines = append(lines, line)
}
}
return fileutil.WriteFileAtomic(path, []byte(strings.Join(lines, "\n")), 0o600)
}
// SaveNavidromeSort persists the given album browse sort type to the
// [navidrome] section of the config file. It rewrites the browse_sort key
// in-place, or appends it after the [navidrome] section if not present.
// If no [navidrome] section exists, one is appended along with the key.
func SaveNavidromeSort(sortType string) error {
path, err := configPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
line := fmt.Sprintf("browse_sort = %q", sortType)
data, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return err
}
// No file: create with section + key.
return fileutil.WriteFileAtomic(path, []byte("[navidrome]\n"+line+"\n"), 0o600)
}
lines := strings.Split(string(data), "\n")
// Try to replace an existing browse_sort inside [navidrome].
inNavidrome := false
for i, l := range lines {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
inNavidrome = strings.ToLower(trimmed[1:len(trimmed)-1]) == "navidrome"
continue
}
if inNavidrome {
k, _, ok := strings.Cut(trimmed, "=")
if ok && strings.TrimSpace(k) == "browse_sort" {
lines[i] = line
return fileutil.WriteFileAtomic(path, []byte(strings.Join(lines, "\n")), 0o600)
}
}
}
// Key not found: append after the last line in the [navidrome] section,
// or append a new [navidrome] section at the end.
inNavidrome = false
insertAt := -1
for i, l := range lines {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
if inNavidrome && insertAt >= 0 {
break // we've moved past [navidrome]
}
inNavidrome = strings.ToLower(trimmed[1:len(trimmed)-1]) == "navidrome"
}
if inNavidrome {
insertAt = i
}
}
if insertAt >= 0 {
// Insert after the last line we saw inside [navidrome].
tail := append([]string{line}, lines[insertAt+1:]...)
lines = append(lines[:insertAt+1], tail...)
} else {
// No [navidrome] section found: append one.
lines = append(lines, "[navidrome]", line)
}
return fileutil.WriteFileAtomic(path, []byte(strings.Join(lines, "\n")), 0o600)
}
// PlayerConfig is the subset of player controls needed to apply config.
type PlayerConfig interface {
SetVolumeMin(db float64)
SetVolume(db float64)
SetSpeed(ratio float64)
SetEQBand(band int, dB float64)
ToggleMono()
}
// PlaylistConfig is the subset of playlist controls needed to apply config.
type PlaylistConfig interface {
CycleRepeat()
ToggleShuffle()
}
// ApplyPlayer applies audio-engine settings from the config.
func (c Config) ApplyPlayer(p PlayerConfig) {
p.SetVolumeMin(c.VolumeMin)
p.SetVolume(c.Volume)
if c.Speed != 0 && c.Speed != 1.0 {
p.SetSpeed(c.Speed)
}
if c.EQPreset == "" || c.EQPreset == "Custom" {
for i, gain := range c.EQ {
p.SetEQBand(i, gain)
}
}
if c.Mono {
p.ToggleMono()
}
}
// ApplyPlaylist applies playlist-state settings from the config.
func (c Config) ApplyPlaylist(pl PlaylistConfig) {
switch c.Repeat {
case "all":
pl.CycleRepeat() // off -> all
case "one":
pl.CycleRepeat() // off -> all
pl.CycleRepeat() // all -> one
}
if c.Shuffle {
pl.ToggleShuffle()
}
}
// SeekStepLargeDuration returns the configured Shift+Left/Right seek jump.
func (c Config) SeekStepLargeDuration() time.Duration {
return time.Duration(c.SeekStepLarge) * time.Second
}
// clamp constrains all Config fields to their valid ranges.
func (c *Config) clamp() {
c.VolumeMin = max(min(c.VolumeMin, 0), -90)
c.Volume = max(min(c.Volume, 6), c.VolumeMin)
if c.Speed < 0.25 || c.Speed > 2.0 {
c.Speed = 1.0
}
c.SeekStepLarge = max(min(c.SeekStepLarge, 600), 6)
c.SampleRate = clampSampleRate(c.SampleRate)
c.BufferMs = max(min(c.BufferMs, 500), 50)
c.ResampleQuality = max(min(c.ResampleQuality, 4), 1)
c.BitDepth = clampBitDepth(c.BitDepth)
c.Spotify.Bitrate = clampSpotifyBitrate(c.Spotify.Bitrate)
c.PaddingH = max(min(c.PaddingH, 10), 0)
c.PaddingV = max(min(c.PaddingV, 5), 0)
if c.LowPower {
c.Visualizer = "none"
}
}
// nearestAllowed returns the value in allowed closest to v.
// allowed must be non-empty.
func nearestAllowed(v int, allowed []int) int {
best := allowed[0]
bestDist := abs(v - best)
for _, a := range allowed[1:] {
if d := abs(v - a); d < bestDist {
best = a
bestDist = d
}
}
return best
}
// clampSampleRate returns the nearest valid sample rate from the allowed set.
// A value of 0 is preserved as-is to signal "auto-detect" to the player.
func clampSampleRate(v int) int {
if v == 0 {
return 0 // auto-detect
}
return nearestAllowed(v, []int{22050, 44100, 48000, 96000, 192000})
}
// clampBitDepth returns the nearest valid bit depth (16 or 32).
func clampBitDepth(v int) int {
if v >= 24 {
return 32
}
return 16
}
func clampSpotifyBitrate(v int) int {
if v <= 0 {
return 320
}
return nearestAllowed(v, []int{96, 160, 320})
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
// parseStringSlice parses a comma-separated list of strings, optionally
// wrapped in square brackets (e.g. `["Music", "Jazz"]` or `Music, Jazz`).
// Leading/trailing whitespace and surrounding quotes are stripped from each element.
func parseStringSlice(val string) []string {
val = strings.Trim(val, "[]")
parts := strings.Split(val, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
p = strings.Trim(p, `"'`)
if p != "" {
result = append(result, p)
}
}
return result
}
// parseEQ parses a TOML-style array like [0, 1.5, -2, ...] into 10 bands.
func parseEQ(val string) [10]float64 {
var bands [10]float64
val = strings.Trim(val, "[]")
parts := strings.Split(val, ",")
for i, p := range parts {
if i >= 10 {
break
}
if v, err := strconv.ParseFloat(strings.TrimSpace(p), 64); err == nil {
bands[i] = max(min(v, 12), -12)
}
}
return bands
}