From 43b9bf3f4029587d1835df7bc0d7fa6a4daa0031 Mon Sep 17 00:00:00 2001 From: aaronkhall Date: Thu, 19 Mar 2026 21:09:20 +1000 Subject: [PATCH 1/6] harden docker env loading and system fallback --- src/config/config.go | 116 +++++++++++++++++++++++--- src/config/config_test.go | 170 ++++++++++++++++++++++++++++++++++++++ src/main/main.go | 1 + 3 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 src/config/config_test.go diff --git a/src/config/config.go b/src/config/config.go index a88068b..c2b538e 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -10,6 +10,7 @@ import ( "time" "github.com/ilyakaznacheev/cleanenv" + "github.com/joho/godotenv" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -159,31 +160,120 @@ type HttpNotif struct { ReceiverURLs []string `env:"HTTP_RECEIVER"` } func (cfg *Config) ReadEnv() { + if cfg.Flags.CfgPath == "" { + cfg.Flags.CfgPath = ".env" + } - // Try to read from .env file first - err := cleanenv.ReadConfig(cfg.Flags.CfgPath, cfg) - if err != nil { - // If the error is because the file doesn't exist, fallback to env vars - if errors.Is(err, os.ErrNotExist) { - if err := cleanenv.ReadEnv(&cfg); err != nil { - slog.Error("failed to load config from env vars", "context", err.Error()) - os.Exit(1) - } - } else { - slog.Error("failed to load config file", "path", cfg.Flags.CfgPath, "context", err.Error()) - os.Exit(1) - } + // Load .env into the process environment if present. + err := godotenv.Load(cfg.Flags.CfgPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + slog.Error("failed to load config file", "path", cfg.Flags.CfgPath, "context", err.Error()) + os.Exit(1) + } + if errors.Is(err, os.ErrNotExist) { + slog.Debug("config file not found, using process environment only", "path", cfg.Flags.CfgPath) + } + + // Read from process env so Docker/container variables are always considered. + if err := cleanenv.ReadEnv(cfg); err != nil { + slog.Error("failed to load config from env vars", "context", err.Error()) + os.Exit(1) } cfg.CommonFixes() } func (cfg *Config) CommonFixes() { + cfg.TrimEnvValues() + cfg.ResolveSystemEnv() cfg.DownloadCfg.Youtube.FileExtension = strings.TrimPrefix(cfg.DownloadCfg.Youtube.FileExtension, ".") cfg.ClientCfg.URL = strings.TrimSuffix(cfg.ClientCfg.URL, "/") cfg.NormalizeDir() } +func (cfg *Config) ResolveSystemEnv() { + if cfg.System == "" { + cfg.System = os.Getenv("EXPLO_SYSTEM") + } + if cfg.System == "" { + cfg.System = os.Getenv("MUSIC_SYSTEM_TYPE") + } + cfg.System = strings.ToLower(strings.TrimSpace(cfg.System)) +} + +func (cfg *Config) TrimEnvValues() { + cfg.System = strings.TrimSpace(cfg.System) + cfg.LogLevel = strings.TrimSpace(cfg.LogLevel) + + cfg.ClientCfg.ClientID = strings.TrimSpace(cfg.ClientCfg.ClientID) + cfg.ClientCfg.LibraryName = strings.TrimSpace(cfg.ClientCfg.LibraryName) + cfg.ClientCfg.URL = strings.TrimSpace(cfg.ClientCfg.URL) + cfg.ClientCfg.DownloadDir = strings.TrimSpace(cfg.ClientCfg.DownloadDir) + cfg.ClientCfg.PlaylistDir = strings.TrimSpace(cfg.ClientCfg.PlaylistDir) + cfg.ClientCfg.PlaylistNFormat = strings.TrimSpace(cfg.ClientCfg.PlaylistNFormat) + + cfg.ClientCfg.Creds.APIKey = strings.TrimSpace(cfg.ClientCfg.Creds.APIKey) + cfg.ClientCfg.Creds.User = strings.TrimSpace(cfg.ClientCfg.Creds.User) + cfg.ClientCfg.Creds.Password = strings.TrimSpace(cfg.ClientCfg.Creds.Password) + cfg.ClientCfg.AdminCreds.User = strings.TrimSpace(cfg.ClientCfg.AdminCreds.User) + cfg.ClientCfg.AdminCreds.Password = strings.TrimSpace(cfg.ClientCfg.AdminCreds.Password) + + cfg.ClientCfg.Subsonic.Version = strings.TrimSpace(cfg.ClientCfg.Subsonic.Version) + cfg.ClientCfg.Subsonic.ID = strings.TrimSpace(cfg.ClientCfg.Subsonic.ID) + + cfg.DownloadCfg.DownloadDir = strings.TrimSpace(cfg.DownloadCfg.DownloadDir) + cfg.DownloadCfg.Discovery = strings.TrimSpace(cfg.DownloadCfg.Discovery) + cfg.DownloadCfg.Services = trimStrings(cfg.DownloadCfg.Services) + + cfg.DownloadCfg.Youtube.APIKey = strings.TrimSpace(cfg.DownloadCfg.Youtube.APIKey) + cfg.DownloadCfg.Youtube.FfmpegPath = strings.TrimSpace(cfg.DownloadCfg.Youtube.FfmpegPath) + cfg.DownloadCfg.Youtube.YtdlpPath = strings.TrimSpace(cfg.DownloadCfg.Youtube.YtdlpPath) + cfg.DownloadCfg.Youtube.FileExtension = strings.TrimSpace(cfg.DownloadCfg.Youtube.FileExtension) + cfg.DownloadCfg.Youtube.CookiesPath = strings.TrimSpace(cfg.DownloadCfg.Youtube.CookiesPath) + cfg.DownloadCfg.Youtube.Filters.Extensions = trimStrings(cfg.DownloadCfg.Youtube.Filters.Extensions) + cfg.DownloadCfg.Youtube.Filters.FilterList = trimStrings(cfg.DownloadCfg.Youtube.Filters.FilterList) + + cfg.DownloadCfg.YoutubeMusic.FfmpegPath = strings.TrimSpace(cfg.DownloadCfg.YoutubeMusic.FfmpegPath) + cfg.DownloadCfg.YoutubeMusic.YtdlpPath = strings.TrimSpace(cfg.DownloadCfg.YoutubeMusic.YtdlpPath) + cfg.DownloadCfg.YoutubeMusic.Filters.Extensions = trimStrings(cfg.DownloadCfg.YoutubeMusic.Filters.Extensions) + cfg.DownloadCfg.YoutubeMusic.Filters.FilterList = trimStrings(cfg.DownloadCfg.YoutubeMusic.Filters.FilterList) + + cfg.DownloadCfg.Slskd.APIKey = strings.TrimSpace(cfg.DownloadCfg.Slskd.APIKey) + cfg.DownloadCfg.Slskd.URL = strings.TrimSpace(cfg.DownloadCfg.Slskd.URL) + cfg.DownloadCfg.Slskd.SlskdDir = strings.TrimSpace(cfg.DownloadCfg.Slskd.SlskdDir) + cfg.DownloadCfg.Slskd.Filters.Extensions = trimStrings(cfg.DownloadCfg.Slskd.Filters.Extensions) + cfg.DownloadCfg.Slskd.Filters.FilterList = trimStrings(cfg.DownloadCfg.Slskd.Filters.FilterList) + + cfg.DiscoveryCfg.Discovery = strings.TrimSpace(cfg.DiscoveryCfg.Discovery) + cfg.DiscoveryCfg.Listenbrainz.Discovery = strings.TrimSpace(cfg.DiscoveryCfg.Listenbrainz.Discovery) + cfg.DiscoveryCfg.Listenbrainz.User = strings.TrimSpace(cfg.DiscoveryCfg.Listenbrainz.User) + + cfg.NotifyCfg.Matrix.UserID = strings.TrimSpace(cfg.NotifyCfg.Matrix.UserID) + cfg.NotifyCfg.Matrix.RoomID = strings.TrimSpace(cfg.NotifyCfg.Matrix.RoomID) + cfg.NotifyCfg.Matrix.HomeServer = strings.TrimSpace(cfg.NotifyCfg.Matrix.HomeServer) + cfg.NotifyCfg.Matrix.AccessToken = strings.TrimSpace(cfg.NotifyCfg.Matrix.AccessToken) + cfg.NotifyCfg.Discord.BotToken = strings.TrimSpace(cfg.NotifyCfg.Discord.BotToken) + cfg.NotifyCfg.Discord.ChannelIDs = trimStrings(cfg.NotifyCfg.Discord.ChannelIDs) + cfg.NotifyCfg.Http.ReceiverURLs = trimStrings(cfg.NotifyCfg.Http.ReceiverURLs) +} + +func trimStrings(values []string) []string { + if len(values) == 0 { + return values + } + + trimmed := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + trimmed = append(trimmed, value) + } + + return trimmed +} + func (cfg *Config) NormalizeDir() { if cfg.System == "mpd" { cfg.ClientCfg.PlaylistDir = fixDir(cfg.ClientCfg.PlaylistDir) diff --git a/src/config/config_test.go b/src/config/config_test.go new file mode 100644 index 0000000..4d7171f --- /dev/null +++ b/src/config/config_test.go @@ -0,0 +1,170 @@ +package config + +import "testing" + +func TestResolveSystemEnv(t *testing.T) { + tests := []struct { + name string + exp string + music string + initial string + wantSystem string + }{ + { + name: "uses EXPLO_SYSTEM when present", + exp: " Plex \n", + music: "emby", + wantSystem: "plex", + }, + { + name: "falls back to MUSIC_SYSTEM_TYPE", + music: " JellyFin\t", + wantSystem: "jellyfin", + }, + { + name: "keeps existing config system if set", + exp: "subsonic", + music: "mpd", + initial: " Emby ", + wantSystem: "emby", + }, + { + name: "empty when none provided", + wantSystem: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("EXPLO_SYSTEM", tt.exp) + t.Setenv("MUSIC_SYSTEM_TYPE", tt.music) + + cfg := &Config{System: tt.initial} + cfg.ResolveSystemEnv() + + if cfg.System != tt.wantSystem { + t.Fatalf("system mismatch: got %q, want %q", cfg.System, tt.wantSystem) + } + }) + } +} + +func TestTrimEnvValues(t *testing.T) { + cfg := &Config{ + System: " plex \n", + LogLevel: " DEBUG ", + ClientCfg: ClientConfig{ + URL: " https://example.com/ \n", + LibraryName: " Music ", + Creds: Credentials{ + User: " user \n", + Password: " pass \t", + }, + }, + DownloadCfg: DownloadConfig{ + DownloadDir: " /data/music \n", + Services: []string{" youtube ", " ", "\nslskd\t"}, + }, + NotifyCfg: NotifyConfig{ + Discord: DiscordNotif{ + ChannelIDs: []string{" 123 ", "", " 456 "}, + }, + Http: HttpNotif{ + ReceiverURLs: []string{" https://a ", " ", "https://b\n"}, + }, + }, + } + + cfg.TrimEnvValues() + + if cfg.System != "plex" { + t.Fatalf("unexpected system after trim: %q", cfg.System) + } + if cfg.LogLevel != "DEBUG" { + t.Fatalf("unexpected log level after trim: %q", cfg.LogLevel) + } + if cfg.ClientCfg.URL != "https://example.com/" { + t.Fatalf("unexpected URL after trim: %q", cfg.ClientCfg.URL) + } + if cfg.ClientCfg.Creds.User != "user" { + t.Fatalf("unexpected user after trim: %q", cfg.ClientCfg.Creds.User) + } + if cfg.ClientCfg.Creds.Password != "pass" { + t.Fatalf("unexpected password after trim: %q", cfg.ClientCfg.Creds.Password) + } + if cfg.DownloadCfg.DownloadDir != "/data/music" { + t.Fatalf("unexpected download dir after trim: %q", cfg.DownloadCfg.DownloadDir) + } + + wantServices := []string{"youtube", "slskd"} + if len(cfg.DownloadCfg.Services) != len(wantServices) { + t.Fatalf("unexpected services length: got %d, want %d", len(cfg.DownloadCfg.Services), len(wantServices)) + } + for i := range wantServices { + if cfg.DownloadCfg.Services[i] != wantServices[i] { + t.Fatalf("unexpected service at index %d: got %q, want %q", i, cfg.DownloadCfg.Services[i], wantServices[i]) + } + } + + wantChannelIDs := []string{"123", "456"} + if len(cfg.NotifyCfg.Discord.ChannelIDs) != len(wantChannelIDs) { + t.Fatalf("unexpected channel IDs length: got %d, want %d", len(cfg.NotifyCfg.Discord.ChannelIDs), len(wantChannelIDs)) + } + for i := range wantChannelIDs { + if cfg.NotifyCfg.Discord.ChannelIDs[i] != wantChannelIDs[i] { + t.Fatalf("unexpected channel id at index %d: got %q, want %q", i, cfg.NotifyCfg.Discord.ChannelIDs[i], wantChannelIDs[i]) + } + } + + wantReceiverURLs := []string{"https://a", "https://b"} + if len(cfg.NotifyCfg.Http.ReceiverURLs) != len(wantReceiverURLs) { + t.Fatalf("unexpected receiver URLs length: got %d, want %d", len(cfg.NotifyCfg.Http.ReceiverURLs), len(wantReceiverURLs)) + } + for i := range wantReceiverURLs { + if cfg.NotifyCfg.Http.ReceiverURLs[i] != wantReceiverURLs[i] { + t.Fatalf("unexpected receiver URL at index %d: got %q, want %q", i, cfg.NotifyCfg.Http.ReceiverURLs[i], wantReceiverURLs[i]) + } + } +} + +func TestCommonFixesNormalizesSystemAndURL(t *testing.T) { + t.Setenv("EXPLO_SYSTEM", "") + t.Setenv("MUSIC_SYSTEM_TYPE", " MPD \n") + + cfg := &Config{ + ClientCfg: ClientConfig{ + URL: " http://localhost:4533/ \n", + PlaylistDir: " /playlists ", + }, + DownloadCfg: DownloadConfig{ + DownloadDir: " /data ", + Slskd: Slskd{ + SlskdDir: " /slskd ", + }, + Youtube: Youtube{ + FileExtension: ".opus", + }, + }, + } + + cfg.CommonFixes() + + if cfg.System != "mpd" { + t.Fatalf("unexpected system after common fixes: %q", cfg.System) + } + if cfg.ClientCfg.URL != "http://localhost:4533" { + t.Fatalf("unexpected URL after common fixes: %q", cfg.ClientCfg.URL) + } + if cfg.ClientCfg.PlaylistDir != "/playlists/" { + t.Fatalf("unexpected playlist dir after common fixes: %q", cfg.ClientCfg.PlaylistDir) + } + if cfg.DownloadCfg.DownloadDir != "/data/" { + t.Fatalf("unexpected download dir after common fixes: %q", cfg.DownloadCfg.DownloadDir) + } + if cfg.DownloadCfg.Slskd.SlskdDir != "/slskd/" { + t.Fatalf("unexpected slskd dir after common fixes: %q", cfg.DownloadCfg.Slskd.SlskdDir) + } + if cfg.DownloadCfg.Youtube.FileExtension != "opus" { + t.Fatalf("unexpected file extension after common fixes: %q", cfg.DownloadCfg.Youtube.FileExtension) + } +} diff --git a/src/main/main.go b/src/main/main.go index e034f4b..6aacc4b 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -41,6 +41,7 @@ func main() { cfg.ReadEnv() cfg.MergeFlags() setup(&cfg) + slog.Info("Configuration resolved", "system", cfg.System) slog.Info("Starting Explo...") httpClient := initHttpClient() From ed0e709270abfd98fcd9fcf1d1ac4e4ad534dc26 Mon Sep 17 00:00:00 2001 From: aaronkhall Date: Thu, 19 Mar 2026 21:55:30 +1000 Subject: [PATCH 2/6] add wrong type ptr fallback test hook Made-with: Cursor --- src/config/config.go | 51 ++++++++++++++++++-- src/config/config_test.go | 97 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 6 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index c2b538e..88b8e3c 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -15,6 +15,8 @@ import ( "golang.org/x/text/language" ) +var cleanenvReadEnv = cleanenv.ReadEnv + type Config struct { DownloadCfg DownloadConfig DiscoveryCfg DiscoveryConfig @@ -58,6 +60,7 @@ type Credentials struct { APIKey string `env:"API_KEY"` User string `env:"SYSTEM_USERNAME"` Password string `env:"SYSTEM_PASSWORD"` + Listenbrainz string `env:"LISTENBRAINZ_TOKEN"` Headers map[string]string Token string Salt string @@ -160,6 +163,11 @@ type HttpNotif struct { ReceiverURLs []string `env:"HTTP_RECEIVER"` } func (cfg *Config) ReadEnv() { + if cfg == nil { + slog.Error("config is nil") + os.Exit(1) + } + if cfg.Flags.CfgPath == "" { cfg.Flags.CfgPath = ".env" } @@ -175,14 +183,50 @@ func (cfg *Config) ReadEnv() { } // Read from process env so Docker/container variables are always considered. - if err := cleanenv.ReadEnv(cfg); err != nil { - slog.Error("failed to load config from env vars", "context", err.Error()) - os.Exit(1) + if err := cleanenvReadEnv(cfg); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "wrong type ptr") { + slog.Warn("cleanenv pointer type failure, applying manual env fallback", "context", err.Error()) + cfg.applyManualEnvFallback() + } else { + slog.Error("failed to load config from env vars", "context", err.Error()) + os.Exit(1) + } } cfg.CommonFixes() } +func readEnvTrimmed(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} + +func (cfg *Config) applyManualEnvFallback() { + if cfg.System == "" { + cfg.System = readEnvTrimmed("MUSIC_SYSTEM_TYPE") + } + if cfg.ClientCfg.URL == "" { + cfg.ClientCfg.URL = readEnvTrimmed("MUSIC_SYSTEM_URL") + } + if cfg.ClientCfg.Creds.APIKey == "" { + cfg.ClientCfg.Creds.APIKey = readEnvTrimmed("MUSIC_SYSTEM_TOKEN") + } + if cfg.DiscoveryCfg.Listenbrainz.User == "" { + cfg.DiscoveryCfg.Listenbrainz.User = readEnvTrimmed("LISTENBRAINZ_USER") + } + if cfg.ClientCfg.Creds.Listenbrainz == "" { + cfg.ClientCfg.Creds.Listenbrainz = readEnvTrimmed("LISTENBRAINZ_TOKEN") + } + if len(cfg.DownloadCfg.Services) == 0 { + downloadType := readEnvTrimmed("DOWNLOAD_TYPE") + if downloadType != "" { + cfg.DownloadCfg.Services = []string{downloadType} + } + } + if cfg.DownloadCfg.DownloadDir == "" { + cfg.DownloadCfg.DownloadDir = readEnvTrimmed("DOWNLOAD_DIR") + } +} + func (cfg *Config) CommonFixes() { cfg.TrimEnvValues() cfg.ResolveSystemEnv() @@ -215,6 +259,7 @@ func (cfg *Config) TrimEnvValues() { cfg.ClientCfg.Creds.APIKey = strings.TrimSpace(cfg.ClientCfg.Creds.APIKey) cfg.ClientCfg.Creds.User = strings.TrimSpace(cfg.ClientCfg.Creds.User) cfg.ClientCfg.Creds.Password = strings.TrimSpace(cfg.ClientCfg.Creds.Password) + cfg.ClientCfg.Creds.Listenbrainz = strings.TrimSpace(cfg.ClientCfg.Creds.Listenbrainz) cfg.ClientCfg.AdminCreds.User = strings.TrimSpace(cfg.ClientCfg.AdminCreds.User) cfg.ClientCfg.AdminCreds.Password = strings.TrimSpace(cfg.ClientCfg.AdminCreds.Password) diff --git a/src/config/config_test.go b/src/config/config_test.go index 4d7171f..d3b82cc 100644 --- a/src/config/config_test.go +++ b/src/config/config_test.go @@ -1,6 +1,9 @@ package config -import "testing" +import ( + "errors" + "testing" +) func TestResolveSystemEnv(t *testing.T) { tests := []struct { @@ -57,8 +60,9 @@ func TestTrimEnvValues(t *testing.T) { URL: " https://example.com/ \n", LibraryName: " Music ", Creds: Credentials{ - User: " user \n", - Password: " pass \t", + User: " user \n", + Password: " pass \t", + Listenbrainz: " lb_token \n", }, }, DownloadCfg: DownloadConfig{ @@ -92,6 +96,9 @@ func TestTrimEnvValues(t *testing.T) { if cfg.ClientCfg.Creds.Password != "pass" { t.Fatalf("unexpected password after trim: %q", cfg.ClientCfg.Creds.Password) } + if cfg.ClientCfg.Creds.Listenbrainz != "lb_token" { + t.Fatalf("unexpected listenbrainz token after trim: %q", cfg.ClientCfg.Creds.Listenbrainz) + } if cfg.DownloadCfg.DownloadDir != "/data/music" { t.Fatalf("unexpected download dir after trim: %q", cfg.DownloadCfg.DownloadDir) } @@ -168,3 +175,87 @@ func TestCommonFixesNormalizesSystemAndURL(t *testing.T) { t.Fatalf("unexpected file extension after common fixes: %q", cfg.DownloadCfg.Youtube.FileExtension) } } + +func TestApplyManualEnvFallbackMapping(t *testing.T) { + t.Setenv("MUSIC_SYSTEM_TYPE", " plex \n") + t.Setenv("MUSIC_SYSTEM_URL", " https://music.local/ \n") + t.Setenv("MUSIC_SYSTEM_TOKEN", " system_token \n") + t.Setenv("LISTENBRAINZ_USER", " lb_user \n") + t.Setenv("LISTENBRAINZ_TOKEN", " lb_token \n") + t.Setenv("DOWNLOAD_TYPE", " youtube \n") + t.Setenv("DOWNLOAD_DIR", " /data/downloads \n") + + cfg := &Config{} + cfg.applyManualEnvFallback() + + if cfg.System != "plex" { + t.Fatalf("unexpected system from fallback: %q", cfg.System) + } + if cfg.ClientCfg.URL != "https://music.local/" { + t.Fatalf("unexpected url from fallback: %q", cfg.ClientCfg.URL) + } + if cfg.ClientCfg.Creds.APIKey != "system_token" { + t.Fatalf("unexpected api key from fallback: %q", cfg.ClientCfg.Creds.APIKey) + } + if cfg.ClientCfg.Creds.Listenbrainz != "lb_token" { + t.Fatalf("unexpected listenbrainz token from fallback: %q", cfg.ClientCfg.Creds.Listenbrainz) + } + if cfg.DiscoveryCfg.Listenbrainz.User != "lb_user" { + t.Fatalf("unexpected listenbrainz user from fallback: %q", cfg.DiscoveryCfg.Listenbrainz.User) + } + if len(cfg.DownloadCfg.Services) != 1 || cfg.DownloadCfg.Services[0] != "youtube" { + t.Fatalf("unexpected services from fallback: %#v", cfg.DownloadCfg.Services) + } + if cfg.DownloadCfg.DownloadDir != "/data/downloads" { + t.Fatalf("unexpected download dir from fallback: %q", cfg.DownloadCfg.DownloadDir) + } +} + +func TestReadEnvFallsBackOnWrongTypePtr(t *testing.T) { + originalReadEnv := cleanenvReadEnv + t.Cleanup(func() { + cleanenvReadEnv = originalReadEnv + }) + + cleanenvReadEnv = func(any) error { + return errors.New("wrong type ptr") + } + + t.Setenv("MUSIC_SYSTEM_TYPE", " subsonic \n") + t.Setenv("MUSIC_SYSTEM_URL", " http://subsonic.local/ \n") + t.Setenv("MUSIC_SYSTEM_TOKEN", " token123 \n") + t.Setenv("LISTENBRAINZ_USER", " lb_user \n") + t.Setenv("LISTENBRAINZ_TOKEN", " lb_token \n") + t.Setenv("DOWNLOAD_TYPE", " slskd \n") + t.Setenv("DOWNLOAD_DIR", " /music/downloads \n") + + cfg := &Config{ + Flags: Flags{ + CfgPath: "__does_not_exist__.env", + }, + } + + cfg.ReadEnv() + + if cfg.System != "subsonic" { + t.Fatalf("unexpected system after wrong type ptr fallback: %q", cfg.System) + } + if cfg.ClientCfg.URL != "http://subsonic.local" { + t.Fatalf("unexpected url after wrong type ptr fallback: %q", cfg.ClientCfg.URL) + } + if cfg.ClientCfg.Creds.APIKey != "token123" { + t.Fatalf("unexpected api key after wrong type ptr fallback: %q", cfg.ClientCfg.Creds.APIKey) + } + if cfg.ClientCfg.Creds.Listenbrainz != "lb_token" { + t.Fatalf("unexpected listenbrainz token after wrong type ptr fallback: %q", cfg.ClientCfg.Creds.Listenbrainz) + } + if cfg.DiscoveryCfg.Listenbrainz.User != "lb_user" { + t.Fatalf("unexpected listenbrainz user after wrong type ptr fallback: %q", cfg.DiscoveryCfg.Listenbrainz.User) + } + if len(cfg.DownloadCfg.Services) != 1 || cfg.DownloadCfg.Services[0] != "slskd" { + t.Fatalf("unexpected services after wrong type ptr fallback: %#v", cfg.DownloadCfg.Services) + } + if cfg.DownloadCfg.DownloadDir != "/music/downloads/" { + t.Fatalf("unexpected download dir after wrong type ptr fallback: %q", cfg.DownloadCfg.DownloadDir) + } +} From 1b2be3550f5788af8b1ec014480e6d1d249b618a Mon Sep 17 00:00:00 2001 From: aaronkhall Date: Thu, 19 Mar 2026 22:00:51 +1000 Subject: [PATCH 3/6] switch config loader to manual env mapping Made-with: Cursor --- src/config/config.go | 40 ++++++++++++++++++++++++++------------- src/config/config_test.go | 26 ++++++++----------------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 88b8e3c..e6d927d 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -9,14 +9,11 @@ import ( "strings" "time" - "github.com/ilyakaznacheev/cleanenv" "github.com/joho/godotenv" "golang.org/x/text/cases" "golang.org/x/text/language" ) -var cleanenvReadEnv = cleanenv.ReadEnv - type Config struct { DownloadCfg DownloadConfig DiscoveryCfg DiscoveryConfig @@ -182,16 +179,9 @@ func (cfg *Config) ReadEnv() { slog.Debug("config file not found, using process environment only", "path", cfg.Flags.CfgPath) } - // Read from process env so Docker/container variables are always considered. - if err := cleanenvReadEnv(cfg); err != nil { - if strings.Contains(strings.ToLower(err.Error()), "wrong type ptr") { - slog.Warn("cleanenv pointer type failure, applying manual env fallback", "context", err.Error()) - cfg.applyManualEnvFallback() - } else { - slog.Error("failed to load config from env vars", "context", err.Error()) - os.Exit(1) - } - } + // Nuclear option: bypass cleanenv parsing completely and populate manually. + cfg.initManualConfig() + cfg.applyManualEnvFallback() cfg.CommonFixes() } @@ -227,6 +217,30 @@ func (cfg *Config) applyManualEnvFallback() { } } +func (cfg *Config) initManualConfig() { + // Explicitly initialize all nested config values to avoid nil-like assumptions. + cfg.ClientCfg = ClientConfig{} + cfg.ClientCfg.Creds = Credentials{} + cfg.ClientCfg.AdminCreds = AdminCredentials{} + cfg.ClientCfg.Subsonic = SubsonicConfig{} + + cfg.DownloadCfg = DownloadConfig{} + cfg.DownloadCfg.Youtube = Youtube{} + cfg.DownloadCfg.YoutubeMusic = YoutubeMusic{} + cfg.DownloadCfg.Slskd = Slskd{} + cfg.DownloadCfg.Slskd.Filters = Filters{} + cfg.DownloadCfg.Youtube.Filters = Filters{} + cfg.DownloadCfg.YoutubeMusic.Filters = Filters{} + + cfg.DiscoveryCfg = DiscoveryConfig{} + cfg.DiscoveryCfg.Listenbrainz = Listenbrainz{} + + cfg.NotifyCfg = NotifyConfig{} + cfg.NotifyCfg.Matrix = MatrixNotif{} + cfg.NotifyCfg.Discord = DiscordNotif{} + cfg.NotifyCfg.Http = HttpNotif{} +} + func (cfg *Config) CommonFixes() { cfg.TrimEnvValues() cfg.ResolveSystemEnv() diff --git a/src/config/config_test.go b/src/config/config_test.go index d3b82cc..3a7f64f 100644 --- a/src/config/config_test.go +++ b/src/config/config_test.go @@ -1,7 +1,6 @@ package config import ( - "errors" "testing" ) @@ -211,16 +210,7 @@ func TestApplyManualEnvFallbackMapping(t *testing.T) { } } -func TestReadEnvFallsBackOnWrongTypePtr(t *testing.T) { - originalReadEnv := cleanenvReadEnv - t.Cleanup(func() { - cleanenvReadEnv = originalReadEnv - }) - - cleanenvReadEnv = func(any) error { - return errors.New("wrong type ptr") - } - +func TestReadEnvManualLoader(t *testing.T) { t.Setenv("MUSIC_SYSTEM_TYPE", " subsonic \n") t.Setenv("MUSIC_SYSTEM_URL", " http://subsonic.local/ \n") t.Setenv("MUSIC_SYSTEM_TOKEN", " token123 \n") @@ -238,24 +228,24 @@ func TestReadEnvFallsBackOnWrongTypePtr(t *testing.T) { cfg.ReadEnv() if cfg.System != "subsonic" { - t.Fatalf("unexpected system after wrong type ptr fallback: %q", cfg.System) + t.Fatalf("unexpected system after manual load: %q", cfg.System) } if cfg.ClientCfg.URL != "http://subsonic.local" { - t.Fatalf("unexpected url after wrong type ptr fallback: %q", cfg.ClientCfg.URL) + t.Fatalf("unexpected url after manual load: %q", cfg.ClientCfg.URL) } if cfg.ClientCfg.Creds.APIKey != "token123" { - t.Fatalf("unexpected api key after wrong type ptr fallback: %q", cfg.ClientCfg.Creds.APIKey) + t.Fatalf("unexpected api key after manual load: %q", cfg.ClientCfg.Creds.APIKey) } if cfg.ClientCfg.Creds.Listenbrainz != "lb_token" { - t.Fatalf("unexpected listenbrainz token after wrong type ptr fallback: %q", cfg.ClientCfg.Creds.Listenbrainz) + t.Fatalf("unexpected listenbrainz token after manual load: %q", cfg.ClientCfg.Creds.Listenbrainz) } if cfg.DiscoveryCfg.Listenbrainz.User != "lb_user" { - t.Fatalf("unexpected listenbrainz user after wrong type ptr fallback: %q", cfg.DiscoveryCfg.Listenbrainz.User) + t.Fatalf("unexpected listenbrainz user after manual load: %q", cfg.DiscoveryCfg.Listenbrainz.User) } if len(cfg.DownloadCfg.Services) != 1 || cfg.DownloadCfg.Services[0] != "slskd" { - t.Fatalf("unexpected services after wrong type ptr fallback: %#v", cfg.DownloadCfg.Services) + t.Fatalf("unexpected services after manual load: %#v", cfg.DownloadCfg.Services) } if cfg.DownloadCfg.DownloadDir != "/music/downloads/" { - t.Fatalf("unexpected download dir after wrong type ptr fallback: %q", cfg.DownloadCfg.DownloadDir) + t.Fatalf("unexpected download dir after manual load: %q", cfg.DownloadCfg.DownloadDir) } } From eada69c9322e5a5a9fccc2e6d89e0f428f97f260 Mon Sep 17 00:00:00 2001 From: aaronkhall Date: Thu, 19 Mar 2026 22:13:42 +1000 Subject: [PATCH 4/6] force explicit system mapping in manual loader Made-with: Cursor --- src/config/config.go | 15 ++++++++++++++- src/config/config_test.go | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/config/config.go b/src/config/config.go index e6d927d..659fa37 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -182,6 +182,15 @@ func (cfg *Config) ReadEnv() { // Nuclear option: bypass cleanenv parsing completely and populate manually. cfg.initManualConfig() cfg.applyManualEnvFallback() + // Explicit system mapping from MUSIC_SYSTEM_TYPE for manual loader mode. + cfg.System = strings.ToLower(readEnvTrimmed("MUSIC_SYSTEM_TYPE")) + if cfg.System == "" { + cfg.System = strings.ToLower(readEnvTrimmed("EXPLO_SYSTEM")) + } + // Temporary hard default to prove system routing works end-to-end. + if cfg.System == "" { + cfg.System = "jellyfin" + } cfg.CommonFixes() } @@ -191,8 +200,9 @@ func readEnvTrimmed(key string) string { } func (cfg *Config) applyManualEnvFallback() { + cfg.System = strings.ToLower(readEnvTrimmed("MUSIC_SYSTEM_TYPE")) if cfg.System == "" { - cfg.System = readEnvTrimmed("MUSIC_SYSTEM_TYPE") + cfg.System = strings.ToLower(readEnvTrimmed("EXPLO_SYSTEM")) } if cfg.ClientCfg.URL == "" { cfg.ClientCfg.URL = readEnvTrimmed("MUSIC_SYSTEM_URL") @@ -215,6 +225,9 @@ func (cfg *Config) applyManualEnvFallback() { if cfg.DownloadCfg.DownloadDir == "" { cfg.DownloadCfg.DownloadDir = readEnvTrimmed("DOWNLOAD_DIR") } + if cfg.System == "" { + cfg.System = "jellyfin" + } } func (cfg *Config) initManualConfig() { diff --git a/src/config/config_test.go b/src/config/config_test.go index 3a7f64f..9ccfc08 100644 --- a/src/config/config_test.go +++ b/src/config/config_test.go @@ -249,3 +249,20 @@ func TestReadEnvManualLoader(t *testing.T) { t.Fatalf("unexpected download dir after manual load: %q", cfg.DownloadCfg.DownloadDir) } } + +func TestReadEnvManualLoaderDefaultsToJellyfin(t *testing.T) { + t.Setenv("MUSIC_SYSTEM_TYPE", "") + t.Setenv("EXPLO_SYSTEM", "") + + cfg := &Config{ + Flags: Flags{ + CfgPath: "__does_not_exist__.env", + }, + } + + cfg.ReadEnv() + + if cfg.System != "jellyfin" { + t.Fatalf("expected default jellyfin system, got %q", cfg.System) + } +} From 93c2679bd1c2e342ef7167a6d22f43eda54acafd Mon Sep 17 00:00:00 2001 From: aaronkhall Date: Thu, 19 Mar 2026 22:30:27 +1000 Subject: [PATCH 5/6] map jellyfin library in manual loader Made-with: Cursor --- src/config/config.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/config/config.go b/src/config/config.go index 659fa37..3de22f1 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -207,6 +207,12 @@ func (cfg *Config) applyManualEnvFallback() { if cfg.ClientCfg.URL == "" { cfg.ClientCfg.URL = readEnvTrimmed("MUSIC_SYSTEM_URL") } + if cfg.ClientCfg.LibraryName == "" { + cfg.ClientCfg.LibraryName = readEnvTrimmed("JELLYFIN_LIBRARY") + } + if cfg.ClientCfg.LibraryName == "" { + cfg.ClientCfg.LibraryName = "Music" + } if cfg.ClientCfg.Creds.APIKey == "" { cfg.ClientCfg.Creds.APIKey = readEnvTrimmed("MUSIC_SYSTEM_TOKEN") } From ba9719f7f6125664ad26942c9615f4d7fac6f78e Mon Sep 17 00:00:00 2001 From: aaronkhall Date: Thu, 19 Mar 2026 22:35:08 +1000 Subject: [PATCH 6/6] guard discovery client initialization Made-with: Cursor --- src/discovery/discovery.go | 8 ++++++-- src/main/main.go | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/discovery/discovery.go b/src/discovery/discovery.go index 9981a2c..d2d2800 100644 --- a/src/discovery/discovery.go +++ b/src/discovery/discovery.go @@ -1,13 +1,14 @@ package discovery import ( + "fmt" "explo/src/models" cfg "explo/src/config" "explo/src/util" ) type DiscoverClient struct { - cfg *cfg.DiscoveryConfig + Config *cfg.DiscoveryConfig Discovery Discovery } type Discovery interface { @@ -15,7 +16,7 @@ type Discovery interface { } func NewDiscoverer(cfg cfg.DiscoveryConfig, httpClient *util.HttpClient) *DiscoverClient { - c := &DiscoverClient{cfg: &cfg} + c := &DiscoverClient{Config: &cfg} switch cfg.Discovery { case "listenbrainz": @@ -27,5 +28,8 @@ func NewDiscoverer(cfg cfg.DiscoveryConfig, httpClient *util.HttpClient) *Discov } func (c *DiscoverClient) Discover() ([]*models.Track, error) { + if c == nil || c.Config == nil || c.Discovery == nil { + return nil, fmt.Errorf("discovery client not initialized") + } return c.Discovery.QueryTracks() } \ No newline at end of file diff --git a/src/main/main.go b/src/main/main.go index 6aacc4b..2db7293 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -50,14 +50,18 @@ func main() { slog.Error(err.Error(), "notify", true) os.Exit(1) } - discovery := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) + discoveryClient := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) + if discoveryClient == nil { + slog.Error("failed to initialize discovery client", "service", cfg.DiscoveryCfg.Discovery, "notify", true) + os.Exit(1) + } downloader, err := downloader.NewDownloader(&cfg.DownloadCfg, httpClient, cfg.Flags.ExcludeLocal) if err != nil { slog.Error(err.Error(), "notify", true) os.Exit(1) } - tracks, err := discovery.Discover() + tracks, err := discoveryClient.Discover() if err != nil { slog.Error(err.Error(), "notify", true) os.Exit(1)