bjarneo--cliamp
850 行
27 KiB
Go
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
|
|
}
|