Files
pmomusic/pmoconfig/config.go

411 lines
9.9 KiB
Go

package pmoconfig
import (
_ "embed"
"fmt"
"os"
"os/user"
"path"
"strconv"
"strings"
"sync"
"gargoton.petite-maison-orange.fr/eric/pmomusic/fileutils"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
//go:embed pmomusic.yaml
var defaultConfig []byte
type Config struct {
path string
mutex sync.Mutex
config map[string]interface{}
}
var _CONFIG *Config
const envConfigFile = "PMOMUSIC_CONFIG"
const envPrefix = "PMOMUSIC_CONFIG__"
// LoadConfig loads a configuration file from the given path or a default
// location.
//
// It prioritizes paths in this order:
// - the provided path,
// - the file specified by the environment variable PMOMUSIC_CONFIG
// - the .pmomusic.yml file in the current directory
// - the .pmomusic.yml file in the user's home directory, and . If no path is found
//
// or it fails to read any of these files, it falls back on a default
// configuration.
//
// Parameters:
// - path string: The path to the configuration file. If this
// parameter is empty, the function will look for the configuration in the
// current user's home directory and an environment variable.
//
// Returns: 1) Config: A struct containing the loaded configuration data.
//
// Side Effects:
//
// - This function reads from disk, logs informational messages, and may also
// panic if there are issues unmarshalling the YAML config file.
//
// Errors:
//
// - The function will log a warning message and continue with a default
// configuration if it fails to read or unmarshal any of the files. It does not
// return an error in this case, as returning errors from within deferred
// functions can cause unexpected behavior.
//
// - If there's an issue reading or unmarshalling the YAML file, the function
// will panic as this is a fatal condition that should be addressed immediately.
//
// Edge Cases:
//
// - This function does not handle race conditions where the
// configuration file could change between when it checks if the path is empty
// and when it attempts to read from it. If the file changes in that time, an
// error will occur.
//
// - This function assumes the YAML config files are formatted correctly. If
// they're not, unmarshalling them into a struct may result in unexpected
// behavior or errors.
//
// Usage: ``` go cfg := LoadConfig("/path/to/config") fmt.Println(cfg) ````
func LoadConfig(filename string) *Config {
var data []byte
var err error
var cfg = &Config{}
cfg.mutex.Lock()
defer cfg.mutex.Unlock()
path := filename
if path != "" {
log.Infof("✅ Trying to load config %s", path)
data, err = os.ReadFile(path)
if err != nil {
log.Warnf("❌ cannot read config file %s", path)
path = ""
}
}
if path == "" {
path = os.Getenv(envConfigFile)
if path != "" {
log.Infof("✅ Trying to load config specified in env var %s", envConfigFile)
data, err = os.ReadFile(path)
if err != nil {
log.Warnf("❌ cannot read config file %s specified in env var %s", path, envConfigFile)
path = ""
}
}
}
if path == "" {
path = ".pmomusic.yml"
dir, err := os.Getwd()
if err != nil {
dir = "."
}
log.Infof("✅ Trying to load config file %s/.pmomusic.yml", dir)
data, err = os.ReadFile(path)
if err != nil {
log.Warnf("❌ I cannot read config file %s/.pmomusic.yml", dir)
path = ""
}
}
if path == "" {
path = getHomeYmlPath()
log.Infof("✅ Trying to load config file from user's home %s", path)
data, err = os.ReadFile(path)
if err != nil {
log.Warnf("❌ I cannot read config file %s", path)
path = ""
}
}
if path == "" {
log.Infof("✅ Using default embeded config")
data = defaultConfig
}
if err := yaml.Unmarshal(data, &cfg.config); err != nil {
log.Panicf("invalid YAML config: %v", err)
}
cfg.config = lowerKeysMap(cfg.config)
applyEnvOverrides(cfg)
if path == "" {
switch {
case filename != "" && fileutils.IsWriteable(filename):
path = filename
case os.Getenv(envConfigFile) != "" && fileutils.IsWriteable(os.Getenv(envConfigFile)):
path = os.Getenv(envConfigFile)
case fileutils.IsWriteable(".pmomusic.yml"):
path = ".pmomusic.yml"
case fileutils.IsWriteable(getHomeYmlPath()):
path = getHomeYmlPath()
}
} else {
if !fileutils.IsWriteable(path) {
path = ""
}
}
if path == "" {
log.Panic("I cannot find a place to store config file")
}
log.Infof("✅ Config file will be stored in %s", path)
cfg.path = path
cfg.mutex.Unlock()
cfg.Save()
cfg.mutex.Lock()
return cfg
}
func (cfg *Config) Save() error {
cfg.mutex.Lock()
defer cfg.mutex.Unlock()
cfg.config = lowerKeysMap(cfg.config)
data, err := yaml.Marshal(cfg.config)
if err != nil {
return err
}
return os.WriteFile(cfg.path, data, 0644)
}
func (cfg *Config) SetValue(path []string, value interface{}) {
cfg.setValue(path, value)
cfg.Save()
}
func (cfg *Config) GetValue(path []string) (interface{}, error) {
cfg.mutex.Lock()
defer cfg.mutex.Unlock()
current := cfg.config
for i, key := range path {
key = strings.ToLower(key)
next, ok := current[key]
if !ok {
return nil, fmt.Errorf("path %s does not exist", strings.Join(path[:i+1], "."))
}
if i < len(path)-1 {
current, ok = next.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("path %s is not a Config", strings.Join(path[:i+1], "."))
}
continue
}
return next, nil
}
return nil, fmt.Errorf("path %s does not exist", strings.Join(path[:], "."))
}
// overrideConfig sets a value in a nested map[string]interface{} at the given path.
func (cfg *Config) setValue(path []string, value interface{}) {
cfg.mutex.Lock()
defer cfg.mutex.Unlock()
current := cfg.config
for i, key := range path {
key = strings.ToLower(key)
if i == len(path)-1 {
current[key] = value
return
}
// ensure intermediate maps exist
if _, ok := current[key]; !ok {
current[key] = make(map[string]interface{})
}
next, ok := current[key].(map[string]interface{})
if !ok {
// If the path conflicts with a non-object, overwrite it
next = make(map[string]interface{})
current[key] = next
}
current = next
}
}
// getHomeYmlPath constructs and returns the path to the home directory of the
// current user followed by ".pmomusic.yml".
//
// This function does not take any parameters but it relies on the
// `user.Current()` function, which can return an error if it fails to determine
// the current user or their home directory. In such cases, a description of the
// error is printed to standard output and the empty string is returned.
//
// The function returns a string representing the path to the file in the
// following format: "$HOME/.pmomusic.yml". It does not return an error value
// since no errors are expected to occur during normal operation.
//
// Side Effects: None. This is a pure function that only depends on input and
// produces output without changing any state or causing side effects, except
// for the printing of potential error messages.
//
// Edge Cases: If `user.Current()` fails to determine the current user's home
// directory or the current user, an empty string is returned and a description
// of the error is printed.
//
// Example usage:
//
// fmt.Println(getHomeYmlPath())
//
// This will print something like "/Users/username/.pmomusic.yml" on macOS or Linux, or "C:\Users\Username\" on Windows if the current user's home directory is "C:\Users\Username".
func getHomeYmlPath() string {
usr, err := user.Current()
if err != nil {
fmt.Println(err)
}
return path.Join(usr.HomeDir, ".pmomusic.yml")
}
func applyEnvOverrides(cfg *Config) {
for _, env := range os.Environ() {
if !strings.HasPrefix(env, envPrefix) {
continue
}
// Split env var into key and value
parts := strings.SplitN(env, "=", 2)
if len(parts) != 2 {
continue
}
keyPath := strings.Split(strings.TrimPrefix(parts[0], envPrefix), "__")
value := parts[1]
overrideConfig(cfg, keyPath, value)
}
}
func convertYAMLScalar(s string) interface{} {
var out interface{}
err := yaml.Unmarshal([]byte(s), &out)
if err != nil {
// fallback: keep string if parsing failed
return s
}
return out
}
func overrideConfig(cfg *Config, keyPath []string, value string) {
iv := convertYAMLScalar(value)
cfg.setValue(keyPath, iv)
}
func lowerKeysMap(m map[string]interface{}) map[string]interface{} {
out := make(map[string]interface{})
for k, v := range m {
lk := strings.ToLower(k)
// si c'est une map imbriquée, traiter récursivement
switch vv := v.(type) {
case map[string]interface{}:
out[lk] = lowerKeysMap(vv)
default:
out[lk] = v
}
}
return out
}
func GetConfig() *Config {
if _CONFIG == nil {
_CONFIG = LoadConfig("")
}
return _CONFIG
}
func (conf *Config) GetBaseURL() string {
url, _ := conf.GetValue([]string{"host", "base_url"})
surl, ok := url.(string)
if !ok {
return ""
}
return surl
}
func (conf *Config) GetHTTPPort() int {
port, _ := conf.GetValue([]string{"host", "http_port"})
switch val := port.(type) {
case int:
return val
case int64:
return int(val)
case float64:
return int(val)
case string:
i, err := strconv.Atoi(val)
if err == nil {
return i
}
default:
return 1900
}
return 1900
}
func (conf *Config) GetDeviceUDN(devtype, name string) string {
udn, error := conf.GetValue([]string{"devices", string(devtype), name, "udn"})
if error != nil {
udn = uuid.New().String()
conf.SetValue([]string{"devices", string(devtype), name, "udn"}, udn)
conf.Save()
}
return udn.(string)
}
func (conf *Config) GetCoverCacheDir() string {
cachedir, _ := conf.GetValue([]string{"host", "cover_cache", "directory"})
if cachedir == nil {
cachedir = "./.pmomusic_covers"
}
sdir, ok := cachedir.(string)
if ok && sdir != "" {
return sdir
}
return "./.pmomusic_covers"
}
func (conf *Config) GetCoverCacheSize() int {
size, _ := conf.GetValue([]string{"host", "cover_cache", "size"})
if size == nil {
return 2000
}
switch v := size.(type) {
case float64:
return int(v)
case int:
return v
default:
return 2000
}
}