2025-09-09 09:07:48 +02:00
package pmoconfig
2025-09-06 17:45:28 +02:00
import (
_ "embed"
"fmt"
"os"
"os/user"
"path"
2025-09-07 07:59:26 +02:00
"strconv"
2025-09-06 17:45:28 +02:00
"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 {
2025-09-09 21:15:53 +02:00
log . Panicf ( "invalid YAML config: %v" , err )
2025-09-06 17:45:28 +02:00
}
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" } )
2025-09-07 07:59:26 +02:00
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 :
2025-09-06 17:45:28 +02:00
return 1900
}
2025-09-07 07:59:26 +02:00
return 1900
2025-09-06 17:45:28 +02:00
}
2025-09-09 09:07:48 +02:00
func ( conf * Config ) GetDeviceUDN ( devtype , name string ) string {
2025-09-06 17:45:28 +02:00
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 )
}
2025-09-09 09:07:48 +02:00
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
}
}