package config import ( "os" "strings" "time" ) // Config holds all runtime configuration, loaded from environment variables. // See .env.example for the full list and defaults. type Config struct { Port string BaseURL string DatabasePath string ImageDir string // on-disk store for editor image uploads // DictPath is DreamDict's built dict.db, read-only, sitting beside // petal.db. It is what gives Petal French, European Portuguese and Spanish // word lookups; without it only the embedded English/Chinese datasets // exist, which is exactly how a laptop checkout runs. A missing file is // therefore not an error — see lexicon.OpenDreamDict. DictPath string // LLM LLMBackend string // "vllm" | "ollama" LLMEndpoint string LLMModel string // checkpoint model (small, fast) LLMChatModel string // Ask Petal model; falls back to LLMModel if empty LLMTimeout time.Duration // TTS (read-aloud). Off unless TTSEndpoint is set — when empty, the /api/tts // route isn't mounted and the frontend falls back to the browser's Web Speech // API. Endpoint points at a local Piper HTTP server. TTSEndpoint string // Piper instance serving the English voice; also the on/off switch // TTSVoices is every language Petal can read aloud, keyed by base language // tag ("en", "zh", "pt", …). Each Piper server loads exactly one model, so // a language *is* an instance — and the instances are discovered from the // environment rather than named in this struct: one // TTS_ENDPOINT_/TTS_VOICE_ pair per language, so the fr and es // pairs cost a compose service and two lines of .env rather than a code // change. English keeps the unsuffixed TTS_ENDPOINT/TTS_VOICE_EN it has // always had. TTSVoices map[string]TTSVoice // TTSPath is the path Piper serves synthesis on. Piper moved it from "/" to // "/synthesize" in 1.6.0 with an unchanged request body, so this is a // version knob, not a feature: millenia's older server keeps the default, // the containerised sidecars set "/synthesize". TTSPath string TTSCacheDir string // on-disk store for synthesized clips (content-addressed) TTSTimeout time.Duration TTSFormat string // mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg // Auth. OIDC against Authentik. Login is enabled only when the issuer, the // client id and the secret are all present; with any of them missing Petal // falls back to the single hardcoded local user, which is what local // development wants and what every deployment did before Phase 16. AuthentikURL string // issuer URL of the Petal provider in Authentik AuthentikClientID string AuthentikClientSecret string // AllowedSubs gates who may sign in, as a comma-separated list of OIDC // subject ids and/or email addresses. Empty means everyone Authentik // authenticates — right for a single-household instance, wrong the moment // the IdP serves an audience wider than Petal's. AllowedSubs string } // TTSVoice is one Piper instance and the single voice it has loaded. type TTSVoice struct { Endpoint string Voice string } // AuthEnabled reports whether real logins are configured. When false, Petal // resolves every request to the local user. func (c *Config) AuthEnabled() bool { return c.AuthentikURL != "" && c.AuthentikClientID != "" && c.AuthentikClientSecret != "" } // Load reads configuration from the environment, applying sane local-dev defaults. func Load() *Config { return &Config{ Port: env("PORT", "8080"), BaseURL: env("BASE_URL", "http://localhost:8080"), DatabasePath: env("DATABASE_PATH", "./data/petal.db"), ImageDir: env("IMAGE_DIR", "./data/images"), DictPath: env("DICT_PATH", "./data/dict.db"), LLMBackend: env("LLM_BACKEND", "vllm"), LLMEndpoint: env("LLM_ENDPOINT", "http://localhost:8000"), LLMModel: env("LLM_MODEL", ""), LLMChatModel: env("LLM_CHAT_MODEL", ""), LLMTimeout: envDuration("LLM_TIMEOUT", 30*time.Second), TTSEndpoint: env("TTS_ENDPOINT", ""), TTSVoices: ttsVoices(os.Environ()), TTSPath: env("TTS_PATH", "/"), TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"), TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second), TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"), AuthentikURL: env("AUTHENTIK_URL", ""), AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""), AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""), AllowedSubs: env("PETAL_ALLOWED_SUBS", ""), } } // ttsVoices reads the Piper instances out of an environment slice (as returned // by os.Environ) into a map keyed by base language tag. // // English is the unsuffixed pair, TTS_ENDPOINT + TTS_VOICE_EN, because that is // what every deployment already sets and read-aloud has always been English // first. Every other language is a TTS_ENDPOINT_/TTS_VOICE_ pair, // discovered rather than enumerated — TTS_ENDPOINT_ZH is what millenia and the // VPS already use, and TTS_ENDPOINT_PT is all the Portuguese pair needs. // // is the *base* tag: an environment variable name cannot hold the hyphen // in "pt-PT", and the handler routes on the base tag anyway (a request for // pt-PT, pt-BR or bare pt reaches the same instance, because there is only one // Portuguese voice loaded). A pair is ignored unless both halves are set: half // a configuration should read as "no voice for this language" and fall back to // the browser, not as an instance that answers every request with an error. func ttsVoices(environ []string) map[string]TTSVoice { vals := make(map[string]string, len(environ)) for _, kv := range environ { if k, v, ok := strings.Cut(kv, "="); ok { vals[k] = v } } voices := map[string]TTSVoice{} add := func(lang, endpoint, voice string) { endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") voice = strings.TrimSpace(voice) if endpoint == "" || voice == "" { return } voices[lang] = TTSVoice{Endpoint: endpoint, Voice: voice} } // The two languages that shipped before this was a map keep their voice // defaults, so an existing deployment that names only the endpoints (as // millenia's unit does) sounds exactly as it did. voiceOr := func(key, fallback string) string { if v := strings.TrimSpace(vals[key]); v != "" { return v } return fallback } add("en", vals["TTS_ENDPOINT"], voiceOr("TTS_VOICE_EN", "en_US-amy-medium")) for k, endpoint := range vals { suffix, ok := strings.CutPrefix(k, "TTS_ENDPOINT_") if !ok || suffix == "" { continue } voice := vals["TTS_VOICE_"+suffix] if suffix == "ZH" { voice = voiceOr("TTS_VOICE_ZH", "zh_CN-huayan-medium") } add(strings.ToLower(suffix), endpoint, voice) } return voices } func env(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func envDuration(key string, fallback time.Duration) time.Duration { if v := os.Getenv(key); v != "" { if d, err := time.ParseDuration(v); err == nil { return d } } return fallback }