package config import ( "fmt" "strconv" "time" "github.com/google/uuid" ) // stringOr returns the value from the map for the given key, or the fallback if absent/empty. func stringOr(m map[string]string, key, fallback string) string { if v, ok := m[key]; ok && v != "" { return v } return fallback } func firstConfiguredString(m map[string]string, fallback string, keys ...string) string { for _, key := range keys { if v, ok := m[key]; ok && v != "" { return v } } return fallback } // intOr returns the parsed integer value from the map for the given key, or the fallback if absent/empty. // Returns an error if the value is present but cannot be parsed. func intOr(m map[string]string, key string, fallback int) (int, error) { v, ok := m[key] if !ok || v == "" { return fallback, nil } n, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf("invalid int for %q: %w", key, err) } return n, nil } // boolOr returns the parsed boolean value from the map for the given key, or the fallback if absent/empty. // Returns an error if the value is present but cannot be parsed. func boolOr(m map[string]string, key string, fallback bool) (bool, error) { v, ok := m[key] if !ok || v == "" { return fallback, nil } b, err := strconv.ParseBool(v) if err != nil { return false, fmt.Errorf("invalid bool for %q: %w", key, err) } return b, nil } func firstConfiguredBool(m map[string]string, fallback bool, keys ...string) (bool, error) { for _, key := range keys { v, ok := m[key] if !ok || v == "" { continue } b, err := strconv.ParseBool(v) if err != nil { return false, fmt.Errorf("invalid bool for %q: %w", key, err) } return b, nil } return fallback, nil } // int64Or returns the parsed int64 value from the map for the given key, or the fallback if absent/empty. func int64Or(m map[string]string, key string, fallback int64) (int64, error) { v, ok := m[key] if !ok || v == "" { return fallback, nil } n, err := strconv.ParseInt(v, 10, 64) if err != nil { return 0, fmt.Errorf("invalid int64 for %q: %w", key, err) } return n, nil } func firstConfiguredInt(m map[string]string, fallback int, keys ...string) (int, error) { for _, key := range keys { v, ok := m[key] if !ok || v == "" { continue } n, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf("invalid int for %q: %w", key, err) } return n, nil } return fallback, nil } // floatOr returns the parsed float64 value from the map for the given key, or the fallback if absent/empty. func floatOr(m map[string]string, key string, fallback float64) (float64, error) { v, ok := m[key] if !ok || v == "" { return fallback, nil } f, err := strconv.ParseFloat(v, 64) if err != nil { return 0, fmt.Errorf("invalid float for %q: %w", key, err) } return f, nil } // durationOr returns the parsed duration value from the map for the given key, or the fallback if absent/empty. // Returns an error if the value is present but cannot be parsed. func durationOr(m map[string]string, key string, fallback time.Duration) (time.Duration, error) { v, ok := m[key] if !ok || v == "" { return fallback, nil } d, err := parseDuration(v) if err != nil { return 0, fmt.Errorf("invalid duration for %q: %w", key, err) } return d, nil } // defaultJellyfinCompatServerIDFromDB is the computed default server ID used when // no server_id is set in the database settings. var defaultJellyfinCompatServerIDFromDB = uuid.NewSHA1( uuid.NameSpaceURL, []byte("https://silo.local/jellycompat"), ).String() // LoadFromDB builds a Config from a map of server_settings key-value pairs. // The map keys use dot-notation (e.g., "auth.jwt_secret"). Missing or empty // keys fall back to the same defaults as setDefaults(). func LoadFromDB(m map[string]string) (*Config, error) { cfg := &Config{} // Server cfg.Server.Listen = stringOr(m, "server.listen", ":8080") cfg.Server.Mode = stringOr(m, "server.mode", "integrated") cfg.Server.LogLevel = stringOr(m, "server.log_level", "info") cfg.Server.LogFormat = stringOr(m, "server.log_format", "text") cfg.Server.LogQuiet = stringOr(m, "server.log_quiet", "") // Database cfg.Database.URL = stringOr(m, "database.url", "") maxConn, err := intOr(m, "database.max_connections", 20) if err != nil { return nil, err } cfg.Database.MaxConnections = maxConn // S3 — Public assets cfg.S3.Public.Endpoint = firstConfiguredString(m, "", "s3.public_endpoint", "s3.operational_endpoint") cfg.S3.Public.ReadEndpoint = firstConfiguredString(m, "", "s3.public_read_endpoint", "s3.operational_public_endpoint") cfg.S3.Public.Region = firstConfiguredString(m, "", "s3.public_region", "s3.operational_region") publicPathStyle, err := firstConfiguredBool(m, true, "s3.public_path_style", "s3.operational_path_style") if err != nil { return nil, err } cfg.S3.Public.PathStyle = publicPathStyle cfg.S3.Public.Bucket = firstConfiguredString(m, "", "s3.public_bucket", "s3.operational_bucket") cfg.S3.Public.KeyPrefix = firstConfiguredString(m, "", "s3.public_key_prefix", "s3.operational_key_prefix") cfg.S3.Public.AccessKey = firstConfiguredString(m, "", "s3.public_access_key", "s3.operational_access_key") cfg.S3.Public.SecretKey = firstConfiguredString(m, "", "s3.public_secret_key", "s3.operational_secret_key") cfg.S3.Public.URLAuth = firstConfiguredString(m, "", "s3.public_url_auth", "s3.operational_url_auth") cfg.S3.Public.TokenSecret = firstConfiguredString(m, "", "s3.public_token_secret", "s3.operational_token_secret") cfg.S3.Public.TokenParam = firstConfiguredString(m, "", "s3.public_token_param", "s3.operational_token_param") publicTokenTTL, err := firstConfiguredInt(m, 0, "s3.public_token_ttl", "s3.operational_token_ttl") if err != nil { return nil, err } cfg.S3.Public.TokenTTL = publicTokenTTL metadataPresignExpiry, err := durationOr(m, "s3.metadata_presign_expiry", 4*time.Hour) if err != nil { return nil, err } cfg.S3.MetadataPresignExpiry = metadataPresignExpiry // S3 — Private internal cfg.S3.Private.Endpoint = firstConfiguredString(m, "", "s3.private_endpoint", "s3.operational_endpoint") cfg.S3.Private.Region = firstConfiguredString(m, "", "s3.private_region", "s3.operational_region") privatePathStyle, err := firstConfiguredBool(m, true, "s3.private_path_style", "s3.operational_path_style") if err != nil { return nil, err } cfg.S3.Private.PathStyle = privatePathStyle cfg.S3.Private.Bucket = firstConfiguredString(m, "", "s3.private_bucket", "s3.operational_bucket") cfg.S3.Private.KeyPrefix = firstConfiguredString(m, "", "s3.private_key_prefix", "s3.operational_key_prefix") cfg.S3.Private.AccessKey = firstConfiguredString(m, "", "s3.private_access_key", "s3.operational_access_key") cfg.S3.Private.SecretKey = firstConfiguredString(m, "", "s3.private_secret_key", "s3.operational_secret_key") // S3 — User DB cfg.S3.UserDB.Endpoint = stringOr(m, "s3.user_db_endpoint", "") cfg.S3.UserDB.Region = stringOr(m, "s3.user_db_region", "") userDBPathStyle, err := boolOr(m, "s3.user_db_path_style", true) if err != nil { return nil, err } cfg.S3.UserDB.PathStyle = userDBPathStyle cfg.S3.UserDB.Bucket = stringOr(m, "s3.user_db_bucket", "") cfg.S3.UserDB.KeyPrefix = stringOr(m, "s3.user_db_key_prefix", "") cfg.S3.UserDB.AccessKey = stringOr(m, "s3.user_db_access_key", "") cfg.S3.UserDB.SecretKey = stringOr(m, "s3.user_db_secret_key", "") // TMDB collection presets (independent of metadata providers) cfg.TMDBAPIKey = stringOr(m, "tmdb.api_key", "") // MDBList list search/browse (lists themselves are public; only discovery // endpoints require an apikey). cfg.MDBListAPIKey = stringOr(m, "mdblist.api_key", "") // UserDB cfg.UserDB.Backend = stringOr(m, "userdb.backend", "postgres") poolMaxOpen, err := intOr(m, "userdb.pool_max_open", 500) if err != nil { return nil, err } cfg.UserDB.PoolMaxOpen = poolMaxOpen idleTimeout, err := durationOr(m, "userdb.idle_timeout", 12*time.Hour) if err != nil { return nil, err } cfg.UserDB.IdleTimeout = idleTimeout litestreamSync, err := durationOr(m, "userdb.litestream_sync", 1*time.Second) if err != nil { return nil, err } cfg.UserDB.LitestreamSync = litestreamSync staleGrace, err := intOr(m, "userdb.stale_grace_seconds", 120) if err != nil { return nil, err } cfg.UserDB.StaleGraceSeconds = staleGrace // Scanner (schedule is now managed by the task manager, not config) scannerWorkers, err := intOr(m, "scanner.workers", 8) if err != nil { return nil, err } cfg.Scanner.Workers = scannerWorkers maxConcurrentLibraries, err := intOr(m, "scanner.max_concurrent_libraries", 1) if err != nil { return nil, err } cfg.Scanner.MaxConcurrentLibraries = maxConcurrentLibraries maxConcurrentScoped, err := intOr(m, "scanner.max_concurrent_scoped", 2) if err != nil { return nil, err } cfg.Scanner.MaxConcurrentScoped = maxConcurrentScoped fileRemovalGrace, err := durationOr(m, "scanner.file_removal_grace", 24*time.Hour) if err != nil { return nil, err } cfg.Scanner.FileRemovalGrace = fileRemovalGrace emptyTrash, err := boolOr(m, "scanner.empty_trash_after_scan", true) if err != nil { return nil, err } cfg.Scanner.EmptyTrashAfterScan = emptyTrash // Matcher matcherWorkers, err := intOr(m, "matcher.workers", 8) if err != nil { return nil, err } cfg.Matcher.Workers = matcherWorkers batchSize, err := intOr(m, "matcher.batch_size", 500) if err != nil { return nil, err } cfg.Matcher.BatchSize = batchSize enableTVSeriesRootQueue, err := boolOr(m, "matcher.enable_tv_series_root_queue", true) if err != nil { return nil, err } if !enableTVSeriesRootQueue { enableTVSeriesRootQueue, err = boolOr(m, "matcher.enable_tv_series_group_queue", false) if err != nil { return nil, err } } cfg.Matcher.EnableTVSeriesRootQueue = enableTVSeriesRootQueue // Metadata cacheImages, err := boolOr(m, "metadata.cache_images", false) if err != nil { return nil, err } cfg.Metadata.CacheImages = cacheImages // Playback cfg.Playback.FFmpegPath = stringOr(m, "playback.ffmpeg_path", "/usr/lib/jellyfin-ffmpeg/ffmpeg") cfg.Playback.TranscodeDir = stringOr(m, "playback.transcode_dir", "/tmp/silo-transcode") cfg.Playback.HWAccel = stringOr(m, "playback.hw_accel", "auto") cfg.Playback.HWDevice = stringOr(m, "playback.hw_device", "") chapterThumbnailWorkers, err := intOr(m, "playback.chapter_thumbnail_workers", 1) if err != nil { return nil, err } cfg.Playback.ChapterThumbnailWorkers = chapterThumbnailWorkers cfg.Playback.ChapterThumbnailExecution = stringOr(m, "playback.chapter_thumbnail_execution", "local") chapterThumbnailNodeCapacity, err := intOr(m, "playback.chapter_thumbnail_node_capacity", 1) if err != nil { return nil, err } cfg.Playback.ChapterThumbnailNodeCapacity = chapterThumbnailNodeCapacity transcodeEnabled, err := boolOr(m, "playback.transcode_enabled", true) if err != nil { return nil, err } cfg.Playback.TranscodeEnabled = transcodeEnabled allowHEVC, err := boolOr(m, "playback.allow_hevc_encoding", false) if err != nil { return nil, err } cfg.Playback.AllowHEVCEncoding = allowHEVC transcodeAheadSegments, err := intOr(m, "playback.transcode_ahead_segments", 30) if err != nil { return nil, err } cfg.Playback.TranscodeAheadSegments = transcodeAheadSegments segmentDuration, err := intOr(m, "playback.segment_duration", 6) if err != nil { return nil, err } cfg.Playback.SegmentDuration = segmentDuration // Redis cfg.Redis.URL = stringOr(m, "redis.url", "") cfg.Redis.SentinelMaster = stringOr(m, "redis.sentinel_master", "") cfg.Redis.SentinelPassword = stringOr(m, "redis.sentinel_password", "") // SentinelAddresses loaded from YAML only (slice not suitable for key-value settings) // Rate Limiting rateLimitEnabled, err := boolOr(m, "ratelimit.enabled", true) if err != nil { return nil, err } cfg.RateLimit.Enabled = rateLimitEnabled cfg.RateLimit.Backend = stringOr(m, "ratelimit.backend", "memory") // Auth cfg.Auth.JWTSecret = stringOr(m, "auth.jwt_secret", "") accessTokenExpiry, err := durationOr(m, "auth.access_token_expiry", 8*time.Hour) if err != nil { return nil, err } cfg.Auth.AccessTokenExpiry = accessTokenExpiry refreshTokenExpiry, err := durationOr(m, "auth.refresh_token_expiry", 30*24*time.Hour) if err != nil { return nil, err } cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry // JellyfinCompat cfg.JellyfinCompat.Listen = stringOr(m, "jellyfin_compat.listen", ":8096") cfg.JellyfinCompat.PublicURL = stringOr(m, "jellyfin_compat.public_url", "http://127.0.0.1:8096") cfg.JellyfinCompat.EmulatedServerVersion = stringOr(m, "jellyfin_compat.emulated_server_version", "10.12.0") cfg.JellyfinCompat.ServerID = stringOr(m, "jellyfin_compat.server_id", defaultJellyfinCompatServerIDFromDB) cfg.JellyfinCompat.ServerName = stringOr(m, "jellyfin_compat.server_name", "Silo") cfg.JellyfinCompat.WebVersion = stringOr(m, "jellyfin_compat.web_version", DefaultJellyfinWebVersion) cfg.JellyfinCompat.WebDir = stringOr(m, "jellyfin_compat.web_dir", DefaultBundledJellyfinWebDir) sessionTTL, err := durationOr(m, "jellyfin_compat.session_ttl", 87600*time.Hour) if err != nil { return nil, err } cfg.JellyfinCompat.SessionTTL = sessionTTL playbackSessionTTL, err := durationOr(m, "jellyfin_compat.playback_session_ttl", 6*time.Hour) if err != nil { return nil, err } cfg.JellyfinCompat.PlaybackSessionTTL = playbackSessionTTL // Recommendations recsEnabled, err := boolOr(m, "recommendations.enabled", false) if err != nil { return nil, err } cfg.Recommendations.Enabled = recsEnabled legacyOpenAIConfigured := m["recommendations.openai_model"] != "" || m["recommendations.openai_api_key"] != "" || m["recommendations.embedding_provider"] == "openai" cfg.Recommendations.EmbeddingBaseURL = stringOr(m, "recommendations.embedding_base_url", func() string { if legacyOpenAIConfigured { return "https://api.openai.com" } return "http://ollama:11434" }()) cfg.Recommendations.EmbeddingModel = stringOr(m, "recommendations.embedding_model", func() string { if model := m["recommendations.openai_model"]; model != "" { return model } if m["recommendations.embedding_provider"] == "openai" { return "text-embedding-3-small" } return "all-minilm" }()) cfg.Recommendations.EmbeddingAuthToken = stringOr(m, "recommendations.embedding_auth_token", stringOr(m, "recommendations.openai_api_key", "")) cfg.Recommendations.EmbeddingsCron = stringOr(m, "recommendations.embeddings_cron", "0 3 * * *") cfg.Recommendations.TasteProfilesCron = stringOr(m, "recommendations.taste_profiles_cron", "0 4 * * *") cfg.Recommendations.RecommendationsCron = stringOr(m, "recommendations.recommendations_cron", "0 5 * * *") tasteDecayHalfLife, err := floatOr(m, "recommendations.taste_decay_half_life_days", 180) if err != nil { return nil, err } cfg.Recommendations.TasteDecayHalfLifeDays = tasteDecayHalfLife diversityLambda, err := floatOr(m, "recommendations.diversity_lambda", 0.7) if err != nil { return nil, err } cfg.Recommendations.DiversityLambda = diversityLambda cfg.Recommendations.CowatchCron = stringOr(m, "recommendations.cowatch_cron", "30 4 * * *") // Download downloadEnabled, err := boolOr(m, "download.enabled", false) if err != nil { return nil, err } cfg.Download.Enabled = downloadEnabled serverBandwidthMbps, err := int64Or(m, "download.server_bandwidth_mbps", 0) if err != nil { return nil, err } userBandwidthMbps, err := int64Or(m, "download.user_bandwidth_mbps", 0) if err != nil { return nil, err } maxConcurrent, err := intOr(m, "download.max_concurrent_per_user", 3) if err != nil { return nil, err } maxPerPeriod, err := intOr(m, "download.max_per_period", 0) if err != nil { return nil, err } periodDuration, err := durationOr(m, "download.period_duration", 24*time.Hour) if err != nil { return nil, err } // Validate download config values. if serverBandwidthMbps < 0 { return nil, fmt.Errorf("invalid value for %q: must be non-negative", "download.server_bandwidth_mbps") } if userBandwidthMbps < 0 { return nil, fmt.Errorf("invalid value for %q: must be non-negative", "download.user_bandwidth_mbps") } const maxMbps int64 = 73_786_976_294_838 // math.MaxInt64 / 125000 if serverBandwidthMbps > maxMbps { return nil, fmt.Errorf("invalid value for %q: exceeds maximum", "download.server_bandwidth_mbps") } if userBandwidthMbps > maxMbps { return nil, fmt.Errorf("invalid value for %q: exceeds maximum", "download.user_bandwidth_mbps") } if maxConcurrent < 0 { return nil, fmt.Errorf("invalid value for %q: must be non-negative", "download.max_concurrent_per_user") } if maxPerPeriod < 0 { return nil, fmt.Errorf("invalid value for %q: must be non-negative", "download.max_per_period") } if maxPerPeriod > 0 && periodDuration <= 0 { return nil, fmt.Errorf("invalid config: %q requires a positive %q", "download.max_per_period", "download.period_duration") } cfg.Download.ServerBandwidthBPS = serverBandwidthMbps * 125000 // Mbps → bytes/sec cfg.Download.UserBandwidthBPS = userBandwidthMbps * 125000 // Mbps → bytes/sec cfg.Download.MaxConcurrentPerUser = maxConcurrent cfg.Download.MaxPerPeriod = maxPerPeriod cfg.Download.PeriodDuration = periodDuration return cfg, nil }