// Package config loads the gateway's settings from the environment. package config import ( "encoding/json" "fmt" "os" "strconv" "strings" "time" _ "time/tzdata" "github.com/ponzischeme89/memby/server/internal/emby" ) type Config struct { ListenAddr string // EmbyURL is how the gateway itself reaches Emby (may be a private/docker address). EmbyURL string // EmbyPublicURL is the address handed to TV clients for direct video playback. // Defaults to EmbyURL; set it when the gateway talks to Emby over a network the // TVs cannot reach. EmbyPublicURL string DatabaseURL string RedisURL string // ClientName is reported to Emby in the X-Emby-Authorization header, so sessions // show up as this in Emby's dashboard. It is deliberately not the app's own name: // the header travels to whatever Emby does with its logs, and the product name has // no business being the thing that identifies a client to a third party. ClientName string // GatewayClientName is reported instead for a request the gateway makes on its own // behalf — the library sync, the health probe, device cleanup, and an operator // signing into the admin console or the web installer. Those are the server asking, // and reporting them as a television made Emby's device list claim a set that does // not exist in the house. GatewayClientName string HomeTTL time.Duration ItemTTL time.Duration SearchTTL time.Duration ScreensaverTTL time.Duration SessionTTL time.Duration // SessionIdleExpiry retires gateway tokens that go unused for this long. SessionIdleExpiry time.Duration // RecommendTTL is how long computed recommendation rows stay warm. Long, because // taste moves slowly and each rebuild costs several Emby queries. RecommendTTL time.Duration // RecommendTimeout bounds a background rebuild, which fans out further than a // normal request and so needs more headroom than UpstreamTimeout. RecommendTimeout time.Duration // RecommendationWeights is an optional JSON overlay on the weighted defaults. RecommendationWeights string // RemoteConfig is the complete, validated presentation document served to TVs. // It is app-scoped and intentionally contains no account, playback or routing state. RemoteConfig RemoteConfig UpstreamTimeout time.Duration // EmbyHealthInterval paces the reachability probe behind the "server not responding" // and "back online" banners. One probe per gateway, not per TV. Zero disables it. EmbyHealthInterval time.Duration // AdminToken guards the operator interface. Empty disables /admin entirely, so an // unconfigured deployment cannot leave it exposed. AdminToken string // PublicURL is the externally reachable Memby gateway address used in update links. PublicURL string // ReleaseDir persists signed APKs published by CI. ReleaseDir string // ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate // from AdminToken so a compromised build runner cannot change maintenance settings. ReleasePublishToken string // SyncInterval is how often the library import runs. Zero disables the schedule. SyncInterval time.Duration // SyncTimeout bounds one import; a full pass over a large library is slow. SyncTimeout time.Duration // SyncOnStart triggers an incremental import at boot. SyncOnStart bool // SyncUserID / SyncAPIKey are an optional Emby service account for imports. Without // them the newest TV session is borrowed instead. SyncUserID string SyncAPIKey string // AnalyticsRetention is how long raw row and journey events are kept before pruning. // Load enforces a 30-day floor so the per-user history promise cannot be configured away. AnalyticsRetention time.Duration // Sonarr is optional. When configured, its local calendar supplies the informational // Five-day "Shows airing" home row. The API key never leaves this server. SonarrURL string SonarrAPIKey string SonarrTTL time.Duration SonarrLocation *time.Location // SonarrAlertWindow is how long after an episode airs the "aired, coming soon" // banner keeps being offered to clients. Zero turns the banners off without // touching the five-day schedule row. SonarrAlertWindow time.Duration // Radarr is optional. Its calendar supplies the five-day digital movie release row. RadarrURL string RadarrAPIKey string RadarrTTL time.Duration RadarrLocation *time.Location // RadarrWebhookToken guards the Radarr "on import" webhook. Empty means the hook // 404s, so an unconfigured deployment cannot be fed alerts by anyone who finds it. RadarrWebhookToken string // RadarrAlertWindow is how long an imported movie keeps being announced to clients. // It is a window rather than a one-shot push because the gateway has no connection // to a TV that is switched off: a set turned on inside the window still gets the // news, and each TV dedupes by alert id. Zero turns the banners off. RadarrAlertWindow time.Duration // Bazarr is optional and is what lets a viewer fetch a subtitle a title does not have. // It writes the file beside the media, so Emby serves the result and the gateway needs // no storage of its own. Unset means the player simply never offers the option. BazarrURL string BazarrAPIKey string // BazarrTTL is how long the movie/series/episode listings are cached. They exist only // to turn an Emby item into the *arr id Bazarr keys on, and a household's library does // not change between one subtitle search and the next. BazarrTTL time.Duration // BazarrTimeout bounds a manual search, which queries live subtitle providers and is // legitimately slow. It is separate from BazarrTTL because a short HTTP timeout here // shows up as "no subtitles found" rather than as an error. BazarrTimeout time.Duration // Tracearr is an optional, read-only source of completion, session-length and // direct-play signals for the per-user For You area. The public API key stays in // the gateway and is never returned to a TV. TracearrURL string TracearrAPIKey string TracearrServerID string // TracearrSyncInterval imports recent changed sessions. FullInterval reconciles // late/out-of-order updates and deletions without needing a source cursor. TracearrSyncInterval time.Duration TracearrFullInterval time.Duration // Prepared pools rebuild daily at RebuildHour in the configured timezone. The age // settings are safety bounds for manual/background fallback paths. ForYouMinRebuildAge time.Duration ForYouRefreshInterval time.Duration ForYouRebuildHour int } func Load() (Config, error) { remoteConfig, err := loadRemoteConfig(os.Getenv("MEMBY_REMOTE_CONFIG_JSON")) if err != nil { return Config{}, err } c := Config{ ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"), EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"), EmbyPublicURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_PUBLIC_URL"), "/"), DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"), RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"), ClientName: env("MEMBY_CLIENT_NAME", "MbyATV"), GatewayClientName: env("MEMBY_GATEWAY_CLIENT_NAME", emby.DefaultGatewayClientName), HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second), ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute), SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute), ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute), SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute), SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour), RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 24*time.Hour), RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second), RecommendationWeights: strings.TrimSpace( os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"), ), RemoteConfig: remoteConfig, AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")), PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"), ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"), ReleasePublishToken: strings.TrimSpace( os.Getenv("MEMBY_RELEASE_PUBLISH_TOKEN"), ), SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour), SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute), SyncOnStart: boolean("MEMBY_SYNC_ON_START", false), SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")), SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")), AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour), UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second), EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second), SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"), SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")), SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute), SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour), RadarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RADARR_URL")), "/"), RadarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_RADARR_API_KEY")), RadarrTTL: duration("MEMBY_RADARR_TTL", 5*time.Minute), RadarrWebhookToken: strings.TrimSpace(os.Getenv("MEMBY_RADARR_WEBHOOK_TOKEN")), RadarrAlertWindow: duration("MEMBY_RADARR_ALERT_WINDOW", 3*time.Hour), BazarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_BAZARR_URL")), "/"), BazarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_BAZARR_API_KEY")), BazarrTTL: duration("MEMBY_BAZARR_TTL", 5*time.Minute), BazarrTimeout: duration("MEMBY_BAZARR_TIMEOUT", 45*time.Second), TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"), TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")), TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")), TracearrSyncInterval: duration("MEMBY_TRACEARR_SYNC_INTERVAL", 5*time.Minute), TracearrFullInterval: duration("MEMBY_TRACEARR_FULL_INTERVAL", 24*time.Hour), ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 24*time.Hour), ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour), ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4), } if c.AnalyticsRetention < 30*24*time.Hour { c.AnalyticsRetention = 30 * 24 * time.Hour } if c.EmbyURL == "" { return c, fmt.Errorf("MEMBY_EMBY_URL is required") } if c.DatabaseURL == "" { return c, fmt.Errorf("MEMBY_DATABASE_URL is required") } if c.EmbyPublicURL == "" { c.EmbyPublicURL = c.EmbyURL } if c.ReleasePublishToken != "" && c.PublicURL == "" { return c, fmt.Errorf("MEMBY_PUBLIC_URL is required when release publishing is enabled") } if (c.SonarrURL == "") != (c.SonarrAPIKey == "") { return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together") } if (c.RadarrURL == "") != (c.RadarrAPIKey == "") { return c, fmt.Errorf("MEMBY_RADARR_URL and MEMBY_RADARR_API_KEY must be set together") } if (c.BazarrURL == "") != (c.BazarrAPIKey == "") { return c, fmt.Errorf("MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY must be set together") } if (c.TracearrURL == "") != (c.TracearrAPIKey == "") { return c, fmt.Errorf("MEMBY_TRACEARR_URL and MEMBY_TRACEARR_API_KEY must be set together") } if c.ForYouRebuildHour < 0 || c.ForYouRebuildHour > 23 { return c, fmt.Errorf("MEMBY_FOR_YOU_REBUILD_HOUR must be between 0 and 23") } if c.RecommendationWeights != "" && !json.Valid([]byte(c.RecommendationWeights)) { return c, fmt.Errorf("MEMBY_RECOMMENDATION_WEIGHTS must be valid JSON") } location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland")) if err != nil { return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err) } c.SonarrLocation = location c.RadarrLocation = location return c, nil } func env(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } func boolean(key string, fallback bool) bool { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback } value, err := strconv.ParseBool(raw) if err != nil { return fallback } return value } func duration(key string, fallback time.Duration) time.Duration { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback } if d, err := time.ParseDuration(raw); err == nil { return d } // Bare numbers are read as seconds, which is friendlier in a compose file. if secs, err := strconv.Atoi(raw); err == nil { return time.Duration(secs) * time.Second } return fallback } func integer(key string, fallback int) int { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback } value, err := strconv.Atoi(raw) if err != nil { return fallback } return value }