package core import ( "errors" "fmt" "os" "path/filepath" "seanime/internal/constants" "seanime/internal/util" "strconv" "github.com/rs/zerolog" "github.com/spf13/viper" ) type Config struct { Version string Server struct { Host string Port int Offline bool UseBinaryPath bool // Makes $SEANIME_WORKING_DIR point to the binary's directory Systray bool DoHUrl string Password string } Database struct { Name string } Web struct { AssetDir string } Logs struct { Dir string } Cache struct { Dir string TranscodeDir string } Offline struct { Dir string AssetDir string } Manga struct { DownloadDir string LocalDir string } Data struct { // Hydrated after config is loaded AppDataDir string WorkingDir string } Extensions struct { Dir string } Anilist struct { ClientID string } Experimental struct { MainServerTorrentStreaming bool } } type ConfigOptions struct { DataDir string // The path to the Seanime data directory, if any OnVersionChange []func(oldVersion string, newVersion string) EmbeddedLogo []byte // The embedded logo IsDesktopSidecar bool // Run as the desktop sidecar } // NewConfig initializes the config func NewConfig(options *ConfigOptions, logger *zerolog.Logger) (*Config, error) { logger.Debug().Msg("app: Initializing config") // Set Seanime's environment variables if os.Getenv("SEANIME_DATA_DIR") != "" { options.DataDir = os.Getenv("SEANIME_DATA_DIR") } defaultHost := "127.0.0.1" defaultPort := 43211 if os.Getenv("SEANIME_SERVER_HOST") != "" { defaultHost = os.Getenv("SEANIME_SERVER_HOST") } if os.Getenv("SEANIME_SERVER_PORT") != "" { var err error defaultPort, err = strconv.Atoi(os.Getenv("SEANIME_SERVER_PORT")) if err != nil { return nil, fmt.Errorf("invalid SEANIME_SERVER_PORT environment variable: %s", os.Getenv("SEANIME_SERVER_PORT")) } } // Initialize the app data directory dataDir, configPath, err := initAppDataDir(options.DataDir, logger) if err != nil { return nil, err } // Set Seanime's default custom environment variables if err = setDataDirEnv(dataDir); err != nil { return nil, err } // Configure viper viper.SetConfigName(constants.ConfigFileName) viper.SetConfigType("toml") viper.SetConfigFile(configPath) // Set default values viper.SetDefault("version", constants.Version) viper.SetDefault("server.host", defaultHost) viper.SetDefault("server.port", defaultPort) viper.SetDefault("server.offline", false) // Use the binary's directory as the working directory environment variable on macOS viper.SetDefault("server.useBinaryPath", true) //viper.SetDefault("server.systray", true) viper.SetDefault("database.name", "seanime") viper.SetDefault("web.assetDir", "$SEANIME_DATA_DIR/assets") viper.SetDefault("cache.dir", "$SEANIME_DATA_DIR/cache") viper.SetDefault("cache.transcodeDir", "$SEANIME_DATA_DIR/cache/transcode") viper.SetDefault("manga.downloadDir", "$SEANIME_DATA_DIR/manga") viper.SetDefault("manga.localDir", "$SEANIME_DATA_DIR/manga-local") viper.SetDefault("logs.dir", "$SEANIME_DATA_DIR/logs") viper.SetDefault("offline.dir", "$SEANIME_DATA_DIR/offline") viper.SetDefault("offline.assetDir", "$SEANIME_DATA_DIR/offline/assets") viper.SetDefault("extensions.dir", "$SEANIME_DATA_DIR/extensions") // Create and populate the config file if it doesn't exist if err = createConfigFile(configPath); err != nil { return nil, err } // Read the config file if err := viper.ReadInConfig(); err != nil { return nil, err } // Unmarshal the config values cfg := &Config{} if err := viper.Unmarshal(cfg); err != nil { return nil, err } // Update the config if the version has changed if err := updateVersion(cfg, options); err != nil { return nil, err } // Before expanding the values, check if we need to override the working directory if err = setWorkingDirEnv(cfg.Server.UseBinaryPath); err != nil { return nil, err } // Expand the values, replacing environment variables expandEnvironmentValues(cfg) cfg.Data.AppDataDir = dataDir cfg.Data.WorkingDir = os.Getenv("SEANIME_WORKING_DIR") // Check validity of the config if err := validateConfig(cfg, logger); err != nil { return nil, err } go loadLogo(options.EmbeddedLogo, dataDir) return cfg, nil } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (cfg *Config) GetServerAddr(df ...string) string { return fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) } func (cfg *Config) GetServerURI(df ...string) string { pAddr := fmt.Sprintf("http://%s", cfg.GetServerAddr(df...)) if cfg.Server.Host == "" || cfg.Server.Host == "0.0.0.0" { pAddr = fmt.Sprintf(":%d", cfg.Server.Port) if len(df) > 0 { pAddr = fmt.Sprintf("http://%s:%d", df[0], cfg.Server.Port) } } return pAddr } func getWorkingDir(useBinaryPath bool) (string, error) { // Get the working directory wd, err := os.Getwd() if err != nil { return "", err } binaryDir := "" if exe, err := os.Executable(); err == nil { if p, err := filepath.EvalSymlinks(exe); err == nil { binaryDir = filepath.Dir(p) binaryDir = filepath.FromSlash(binaryDir) } } if useBinaryPath && binaryDir != "" { return binaryDir, nil } //// Use the binary's directory as the working directory if needed //if useBinaryPath { // exe, err := os.Executable() // if err != nil { // return wd, nil // Fallback to working dir // } // p, err := filepath.EvalSymlinks(exe) // if err != nil { // return wd, nil // Fallback to working dir // } // wd = filepath.Dir(p) // Set the binary's directory as the working directory // return wd, nil //} return wd, nil } func setDataDirEnv(dataDir string) error { // Set the data directory environment variable if os.Getenv("SEANIME_DATA_DIR") == "" { if err := os.Setenv("SEANIME_DATA_DIR", dataDir); err != nil { return err } } return nil } func setWorkingDirEnv(useBinaryPath bool) error { // Set the working directory environment variable wd, err := getWorkingDir(useBinaryPath) if err != nil { return err } if err = os.Setenv("SEANIME_WORKING_DIR", filepath.FromSlash(wd)); err != nil { return err } return nil } // validateConfig checks if the config values are valid func validateConfig(cfg *Config, logger *zerolog.Logger) error { if cfg.Server.Host == "" { return errInvalidConfigValue("server.host", "cannot be empty") } if cfg.Server.Port == 0 { return errInvalidConfigValue("server.port", "cannot be 0") } if cfg.Database.Name == "" { return errInvalidConfigValue("database.name", "cannot be empty") } if cfg.Web.AssetDir == "" { return errInvalidConfigValue("web.assetDir", "cannot be empty") } if err := checkIsValidPath(cfg.Web.AssetDir); err != nil { return wrapInvalidConfigValue("web.assetDir", err) } if cfg.Cache.Dir == "" { return errInvalidConfigValue("cache.dir", "cannot be empty") } if err := checkIsValidPath(cfg.Cache.Dir); err != nil { return wrapInvalidConfigValue("cache.dir", err) } if cfg.Cache.TranscodeDir == "" { return errInvalidConfigValue("cache.transcodeDir", "cannot be empty") } if err := checkIsValidPath(cfg.Cache.TranscodeDir); err != nil { return wrapInvalidConfigValue("cache.transcodeDir", err) } if cfg.Logs.Dir == "" { return errInvalidConfigValue("logs.dir", "cannot be empty") } if err := checkIsValidPath(cfg.Logs.Dir); err != nil { return wrapInvalidConfigValue("logs.dir", err) } if cfg.Manga.DownloadDir == "" { return errInvalidConfigValue("manga.downloadDir", "cannot be empty") } if err := checkIsValidPath(cfg.Manga.DownloadDir); err != nil { return wrapInvalidConfigValue("manga.downloadDir", err) } if cfg.Manga.LocalDir == "" { return errInvalidConfigValue("manga.localDir", "cannot be empty") } if err := checkIsValidPath(cfg.Manga.LocalDir); err != nil { return wrapInvalidConfigValue("manga.localDir", err) } if cfg.Extensions.Dir == "" { return errInvalidConfigValue("extensions.dir", "cannot be empty") } if err := checkIsValidPath(cfg.Extensions.Dir); err != nil { return wrapInvalidConfigValue("extensions.dir", err) } // Uncomment if "MainServerTorrentStreaming" is no longer an experimental feature if cfg.Experimental.MainServerTorrentStreaming { logger.Warn().Msgf("app: 'Main Server Torrent Streaming' feature is no longer experimental, remove the flag from your config file") } return nil } func checkIsValidPath(path string) error { ok := filepath.IsAbs(path) if !ok { return errors.New("path is not an absolute path") } return nil } // errInvalidConfigValue returns an error for an invalid config value func errInvalidConfigValue(s string, s2 string) error { return fmt.Errorf("invalid config value: \"%s\" %s", s, s2) } func wrapInvalidConfigValue(s string, err error) error { return fmt.Errorf("invalid config value: \"%s\" %w", s, err) } func updateVersion(cfg *Config, opts *ConfigOptions) error { defer func() { if r := recover(); r != nil { // Do nothing } }() if cfg.Version != constants.Version { for _, f := range opts.OnVersionChange { f(cfg.Version, constants.Version) } cfg.Version = constants.Version } viper.Set("version", constants.Version) return viper.WriteConfig() } func expandEnvironmentValues(cfg *Config) { defer func() { if r := recover(); r != nil { // Do nothing } }() cfg.Web.AssetDir = filepath.FromSlash(os.ExpandEnv(cfg.Web.AssetDir)) cfg.Cache.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Cache.Dir)) cfg.Cache.TranscodeDir = filepath.FromSlash(os.ExpandEnv(cfg.Cache.TranscodeDir)) cfg.Logs.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Logs.Dir)) cfg.Manga.DownloadDir = filepath.FromSlash(os.ExpandEnv(cfg.Manga.DownloadDir)) cfg.Manga.LocalDir = filepath.FromSlash(os.ExpandEnv(cfg.Manga.LocalDir)) cfg.Offline.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Offline.Dir)) cfg.Offline.AssetDir = filepath.FromSlash(os.ExpandEnv(cfg.Offline.AssetDir)) cfg.Extensions.Dir = filepath.FromSlash(os.ExpandEnv(cfg.Extensions.Dir)) } // createConfigFile creates a default config file if it doesn't exist func createConfigFile(configPath string) error { _, err := os.Stat(configPath) if os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { return err } if err := viper.WriteConfig(); err != nil { return err } } return nil } func initAppDataDir(definedDataDir string, logger *zerolog.Logger) (dataDir string, configPath string, err error) { // User defined data directory if definedDataDir != "" { // Expand environment variables definedDataDir = filepath.FromSlash(os.ExpandEnv(definedDataDir)) if !filepath.IsAbs(definedDataDir) { return "", "", errors.New("app: Data directory path must be absolute") } // Replace the default data directory dataDir = definedDataDir logger.Trace().Str("dataDir", dataDir).Msg("app: Overriding default data directory") } else { // Default OS data directory // windows: %APPDATA% // unix: $XDG_CONFIG_HOME or $HOME // darwin: $HOME/Library/Application Support dataDir, err = os.UserConfigDir() if err != nil { return "", "", err } // Get the app directory dataDir = filepath.Join(dataDir, "Seanime") } // Create data dir if it doesn't exist if err := os.MkdirAll(dataDir, 0700); err != nil { return "", "", err } // Get the config file path // Normalize the config file path configPath = filepath.FromSlash(filepath.Join(dataDir, constants.ConfigFileName)) // Normalize the data directory path dataDir = filepath.FromSlash(dataDir) return } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func loadLogo(embeddedLogo []byte, dataDir string) (err error) { defer util.HandlePanicInModuleWithError("core/loadLogo", &err) if len(embeddedLogo) == 0 { return nil } logoPath := filepath.Join(dataDir, "logo.png") if _, err = os.Stat(logoPath); os.IsNotExist(err) { if err = os.WriteFile(logoPath, embeddedLogo, 0644); err != nil { return err } } return nil }