850 lines
27 KiB
Go
850 lines
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
|
||
}
|