Files
seanime-docker/seanime-2.9.10/internal/core/config.go
2025-09-20 14:08:38 +01:00

440 lines
12 KiB
Go

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
}