2026-10-18 15:38:56 +02:00
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
2026-10-18 17:10:26 +02:00
|
|
|
|
"strconv"
|
2026-10-18 20:06:05 +02:00
|
|
|
|
"strings"
|
2026-10-18 17:40:35 +02:00
|
|
|
|
"time"
|
2026-10-18 15:38:56 +02:00
|
|
|
|
|
|
|
|
|
|
"github.com/BurntSushi/toml"
|
2026-10-18 17:40:35 +02:00
|
|
|
|
|
|
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
2026-10-18 15:38:56 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Allowed values for audio.sink (public contract).
|
2026-10-18 15:38:56 +02:00
|
|
|
|
const (
|
|
|
|
|
|
SinkOto = "oto"
|
|
|
|
|
|
SinkNull = "null"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// DefaultServerPort is the daemon's default HTTP port on 127.0.0.1.
|
2026-10-18 17:10:26 +02:00
|
|
|
|
const DefaultServerPort = 7800
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// DefaultRateLimit is the anti-spam law's default: one announcement per
|
|
|
|
|
|
// 30 seconds. "0s" disables the limit.
|
2026-10-18 17:40:35 +02:00
|
|
|
|
const DefaultRateLimit = 30 * time.Second
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// DefaultMaxText is the default cap on announcement length in characters:
|
|
|
|
|
|
// two to three sentences, roughly 25 seconds of speech. 0 = unlimited.
|
2026-10-24 09:54:40 +02:00
|
|
|
|
const DefaultMaxText = 400
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// DefaultMaxAge is the default longest wait in the queue; whatever parks
|
|
|
|
|
|
// longer is stale and gets discarded. "0s" = unlimited.
|
2026-10-24 09:54:40 +02:00
|
|
|
|
const DefaultMaxAge = 2 * time.Minute
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// DefaultLang is the language assumed when detection cannot decide (empty,
|
|
|
|
|
|
// numbers, ties). Also the language of talk's own announcements such as the
|
|
|
|
|
|
// wrap result when the label gives no hint.
|
|
|
|
|
|
const DefaultLang = "en"
|
|
|
|
|
|
|
|
|
|
|
|
// Config is the effective configuration after all layers were applied.
|
2026-10-18 15:38:56 +02:00
|
|
|
|
type Config struct {
|
|
|
|
|
|
Audio Audio `toml:"audio"`
|
2026-10-18 16:34:24 +02:00
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Server configures the daemon's HTTP server on 127.0.0.1.
|
2026-10-18 17:10:26 +02:00
|
|
|
|
Server Server `toml:"server"`
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Queue holds the daemon's anti-spam laws.
|
2026-10-18 17:40:35 +02:00
|
|
|
|
Queue Queue `toml:"queue"`
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Daemon configures the background process (autostart).
|
2026-10-24 09:49:16 +02:00
|
|
|
|
Daemon Daemon `toml:"daemon"`
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Lang is the fallback language (de | en) when detection cannot decide,
|
|
|
|
|
|
// and the language of talk's own announcements. Default "en"; env
|
|
|
|
|
|
// TALK_LANG.
|
|
|
|
|
|
Lang string `toml:"lang"`
|
|
|
|
|
|
|
|
|
|
|
|
// Provider pins the chain to exactly one provider (for example
|
|
|
|
|
|
// `provider = "elevenlabs"`); empty = the full default chain
|
|
|
|
|
|
// (elevenlabs → openai → edge → osbuiltin). The --provider flag beats
|
|
|
|
|
|
// this value. Valid names are checked by the provider layer.
|
2026-10-18 16:34:24 +02:00
|
|
|
|
Provider string `toml:"provider"`
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Providers holds per-provider settings (endpoint, model):
|
|
|
|
|
|
// `[providers.elevenlabs] endpoint = "…"`. The key is plural so the pin
|
|
|
|
|
|
// (`provider`) and the settings table can coexist in TOML.
|
2026-10-18 16:34:24 +02:00
|
|
|
|
Providers map[string]ProviderSettings `toml:"providers"`
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Voice is the voice mapping `voice.<provider>.<lang> = id`, for example
|
|
|
|
|
|
// `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. It beats the built-in
|
|
|
|
|
|
// neutral defaults; the --voice flag beats everything.
|
2026-10-18 16:34:24 +02:00
|
|
|
|
Voice map[string]map[string]string `toml:"voice"`
|
2026-10-18 17:40:35 +02:00
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Wrap configures `talk wrap`.
|
2026-10-18 20:46:39 +02:00
|
|
|
|
Wrap Wrap `toml:"wrap"`
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// QuietHours is the quiet window "HH:MM-HH:MM" in local time (may span
|
|
|
|
|
|
// midnight, for example "18:00-08:00"); empty = disabled. Deliberately
|
|
|
|
|
|
// no default window: a law that discards by time of day would silence
|
|
|
|
|
|
// every evening cron job; quiet hours are opt-in like mute.
|
2026-10-18 17:40:35 +02:00
|
|
|
|
QuietHours string `toml:"quiet_hours"`
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// QuietMode is the behaviour during quiet hours: "discard" (default,
|
|
|
|
|
|
// drop silently) or "toast" (desktop notification instead of audio).
|
2026-10-18 17:40:35 +02:00
|
|
|
|
QuietMode string `toml:"quiet_mode"`
|
2026-10-18 15:38:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Audio configures playback.
|
2026-10-18 15:38:56 +02:00
|
|
|
|
type Audio struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Sink selects playback: "oto" (embedded playback, default) or "null"
|
|
|
|
|
|
// (discard; CI, servers, tests).
|
2026-10-18 15:38:56 +02:00
|
|
|
|
Sink string `toml:"sink"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Server holds the daemon's network settings (loopback only; the daemon
|
|
|
|
|
|
// binds 127.0.0.1, the config only supplies the port).
|
2026-10-18 17:10:26 +02:00
|
|
|
|
type Server struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Port of the daemon's HTTP server on 127.0.0.1: `[server] port = 7800`,
|
|
|
|
|
|
// env override TALK_PORT, default 7800.
|
2026-10-18 17:10:26 +02:00
|
|
|
|
Port int `toml:"port"`
|
2026-10-18 20:06:05 +02:00
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// AllowedOrigins is the CORS allow-list for POST /speak: exact origins
|
|
|
|
|
|
// such as "https://chat.example.com". The entry "*" allows every origin
|
|
|
|
|
|
// explicitly. Deliberate default: EMPTY, meaning no CORS headers at all
|
|
|
|
|
|
// and the server-side origin guard rejects browser requests; the door
|
|
|
|
|
|
// stays shut until configured (env TALK_ALLOWED_ORIGINS, comma-separated;
|
|
|
|
|
|
// "off" clears it).
|
2026-10-18 20:06:05 +02:00
|
|
|
|
AllowedOrigins []string `toml:"allowed_origins"`
|
2026-10-18 17:10:26 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Daemon holds the background process settings (TOML `[daemon]`).
|
2026-10-24 09:49:16 +02:00
|
|
|
|
type Daemon struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Autostart lets talk start the daemon itself in the background on the
|
|
|
|
|
|
// first speak command (default true; env TALK_AUTOSTART). false is for
|
|
|
|
|
|
// setups that manage the daemon themselves (systemd, launchd, Task
|
|
|
|
|
|
// Scheduler): without a daemon, say then speaks directly, with a note.
|
2026-10-24 09:49:16 +02:00
|
|
|
|
Autostart bool `toml:"autostart"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Wrap holds the `talk wrap` settings (TOML `[wrap]`).
|
2026-10-18 20:46:39 +02:00
|
|
|
|
type Wrap struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Label replaces the command's base name in the announcement, for
|
|
|
|
|
|
// example `label = "Deployment"` to hear "Deployment succeeded" instead
|
|
|
|
|
|
// of "terraform succeeded" (env override TALK_WRAP_LABEL).
|
2026-10-18 20:46:39 +02:00
|
|
|
|
Label string `toml:"label"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Queue holds the daemon's anti-spam laws (TOML `[queue]`).
|
2026-10-18 17:40:35 +02:00
|
|
|
|
type Queue struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// RateLimit is the minimum gap between two announcement starts, as a
|
|
|
|
|
|
// duration string ("30s"; "0s" = unlimited). Default 30 s.
|
2026-10-18 17:40:35 +02:00
|
|
|
|
RateLimit time.Duration `toml:"rate_limit"`
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// AllowHighBurst lets priority=high deliberately break the rate limit
|
|
|
|
|
|
// (default true). Quiet hours still apply.
|
2026-10-18 17:40:35 +02:00
|
|
|
|
AllowHighBurst bool `toml:"allow_high_burst"`
|
2026-10-24 09:54:40 +02:00
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// MaxText caps announcement length in characters; longer texts are cut
|
|
|
|
|
|
// at a word boundary (default 400; 0 = unlimited; env TALK_MAX_TEXT).
|
2026-10-24 09:54:40 +02:00
|
|
|
|
MaxText int `toml:"max_text"`
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// MaxAge is the longest an announcement may wait in the queue; older
|
|
|
|
|
|
// ones are discarded when they come due (default "2m"; "0s" =
|
|
|
|
|
|
// unlimited; env TALK_MAX_AGE).
|
2026-10-24 09:54:40 +02:00
|
|
|
|
MaxAge time.Duration `toml:"max_age"`
|
2026-10-18 17:40:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// ProviderSettings are the per-provider endpoint and model (TOML
|
|
|
|
|
|
// `[providers.<name>]`). Empty = built-in defaults; the env overrides
|
|
|
|
|
|
// TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT / TALK_EDGE_ENDPOINT
|
|
|
|
|
|
// (read by the provider layer) beat the file. Keys come exclusively from
|
|
|
|
|
|
// TALK_ELEVENLABS_KEY / TALK_OPENAI_KEY and are never written to the file.
|
2026-10-18 16:34:24 +02:00
|
|
|
|
type ProviderSettings struct {
|
|
|
|
|
|
Endpoint string `toml:"endpoint"`
|
2026-10-24 11:15:35 +02:00
|
|
|
|
Model string `toml:"model"` // openai only (default "tts-1")
|
2026-10-18 16:34:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Defaults returns the built-in default configuration (the lowest layer).
|
2026-10-18 15:38:56 +02:00
|
|
|
|
func Defaults() Config {
|
2026-10-18 17:10:26 +02:00
|
|
|
|
return Config{
|
2026-10-18 17:40:35 +02:00
|
|
|
|
Audio: Audio{Sink: SinkOto},
|
|
|
|
|
|
Server: Server{Port: DefaultServerPort},
|
2026-10-24 09:54:40 +02:00
|
|
|
|
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true, MaxText: DefaultMaxText, MaxAge: DefaultMaxAge},
|
2026-10-24 09:49:16 +02:00
|
|
|
|
Daemon: Daemon{Autostart: true},
|
2026-10-24 11:15:35 +02:00
|
|
|
|
Lang: DefaultLang,
|
2026-10-18 17:40:35 +02:00
|
|
|
|
QuietMode: queue.QuietModeDiscard,
|
2026-10-18 17:10:26 +02:00
|
|
|
|
}
|
2026-10-18 15:38:56 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Options controls Load. Every field is injectable so unit tests run without
|
|
|
|
|
|
// a real HOME, environment or files.
|
2026-10-18 15:38:56 +02:00
|
|
|
|
type Options struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// File overrides the platform-native path of config.toml (tests inject
|
|
|
|
|
|
// a temp file). Empty: Dir() + "config.toml".
|
2026-10-18 15:38:56 +02:00
|
|
|
|
File string
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Getenv supplies environment variables (default os.Getenv): the TALK_*
|
|
|
|
|
|
// overrides and the path resolution (HOME, XDG_CONFIG_HOME, APPDATA).
|
2026-10-18 15:38:56 +02:00
|
|
|
|
Getenv func(string) string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Load resolves the configuration in layers: defaults → config.toml (if
|
|
|
|
|
|
// present) → env overrides (TALK_* beats the file). Env and file validation
|
|
|
|
|
|
// errors are reported clearly, never ignored. If the config directory
|
|
|
|
|
|
// cannot be determined (no HOME), talk runs with defaults + env: the file is
|
|
|
|
|
|
// optional, a broken environment must not make talk unusable.
|
2026-10-18 15:38:56 +02:00
|
|
|
|
func Load(opts Options) (Config, error) {
|
|
|
|
|
|
if opts.Getenv == nil {
|
|
|
|
|
|
opts.Getenv = os.Getenv
|
|
|
|
|
|
}
|
|
|
|
|
|
cfg := Defaults()
|
|
|
|
|
|
|
|
|
|
|
|
path := opts.File
|
|
|
|
|
|
if path == "" {
|
|
|
|
|
|
dir, err := Dir(opts.Getenv)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return applyEnv(cfg, opts.Getenv)
|
|
|
|
|
|
}
|
|
|
|
|
|
path = filepath.Join(dir, fileName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
|
|
|
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
|
|
|
|
return cfg, fmt.Errorf("config %s: %w", path, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return applyEnv(cfg, opts.Getenv)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// applyEnv applies the env overrides and validates the result.
|
2026-10-18 15:38:56 +02:00
|
|
|
|
func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
|
|
|
|
|
|
if v := getenv("TALK_AUDIO_SINK"); v != "" {
|
|
|
|
|
|
cfg.Audio.Sink = v
|
|
|
|
|
|
}
|
2026-10-18 16:34:24 +02:00
|
|
|
|
if v := getenv("TALK_PROVIDER"); v != "" {
|
|
|
|
|
|
cfg.Provider = v
|
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
|
if v := getenv("TALK_LANG"); v != "" {
|
|
|
|
|
|
cfg.Lang = v
|
|
|
|
|
|
}
|
2026-10-18 17:10:26 +02:00
|
|
|
|
if v := getenv("TALK_PORT"); v != "" {
|
|
|
|
|
|
port, err := strconv.Atoi(v)
|
|
|
|
|
|
if err != nil || port < 1 || port > 65535 {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("TALK_PORT: invalid value %q (1–65535)", v)
|
2026-10-18 17:10:26 +02:00
|
|
|
|
}
|
|
|
|
|
|
cfg.Server.Port = port
|
|
|
|
|
|
}
|
2026-10-18 20:06:05 +02:00
|
|
|
|
if v := getenv("TALK_ALLOWED_ORIGINS"); v != "" {
|
|
|
|
|
|
if v == "off" {
|
|
|
|
|
|
cfg.Server.AllowedOrigins = nil
|
|
|
|
|
|
} else {
|
|
|
|
|
|
origins, err := splitOrigins(v)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return cfg, fmt.Errorf("TALK_ALLOWED_ORIGINS: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
cfg.Server.AllowedOrigins = origins
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-24 09:54:40 +02:00
|
|
|
|
if v := getenv("TALK_MAX_TEXT"); v != "" {
|
|
|
|
|
|
n, err := strconv.Atoi(v)
|
|
|
|
|
|
if err != nil || n < 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("TALK_MAX_TEXT: invalid value %q (characters; 0 = unlimited)", v)
|
2026-10-24 09:54:40 +02:00
|
|
|
|
}
|
|
|
|
|
|
cfg.Queue.MaxText = n
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := getenv("TALK_MAX_AGE"); v != "" {
|
|
|
|
|
|
d, err := time.ParseDuration(v)
|
|
|
|
|
|
if err != nil || d < 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("TALK_MAX_AGE: invalid value %q (duration such as \"2m\"; \"0s\" = unlimited)", v)
|
2026-10-24 09:54:40 +02:00
|
|
|
|
}
|
|
|
|
|
|
cfg.Queue.MaxAge = d
|
|
|
|
|
|
}
|
2026-10-24 09:49:16 +02:00
|
|
|
|
if v := getenv("TALK_AUTOSTART"); v != "" {
|
|
|
|
|
|
b, err := strconv.ParseBool(v)
|
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("TALK_AUTOSTART: invalid value %q (true/false)", v)
|
2026-10-24 09:49:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
cfg.Daemon.Autostart = b
|
|
|
|
|
|
}
|
2026-10-18 20:46:39 +02:00
|
|
|
|
if v := getenv("TALK_WRAP_LABEL"); v != "" {
|
|
|
|
|
|
cfg.Wrap.Label = v
|
|
|
|
|
|
}
|
2026-10-18 17:40:35 +02:00
|
|
|
|
if v := getenv("TALK_RATE_LIMIT"); v != "" {
|
|
|
|
|
|
d, err := time.ParseDuration(v)
|
|
|
|
|
|
if err != nil || d < 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("TALK_RATE_LIMIT: invalid value %q (duration such as \"30s\"; \"0s\" = unlimited)", v)
|
2026-10-18 17:40:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
cfg.Queue.RateLimit = d
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := getenv("TALK_ALLOW_HIGH_BURST"); v != "" {
|
|
|
|
|
|
b, err := strconv.ParseBool(v)
|
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("TALK_ALLOW_HIGH_BURST: invalid value %q (true/false)", v)
|
2026-10-18 17:40:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
cfg.Queue.AllowHighBurst = b
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := getenv("TALK_QUIET_HOURS"); v != "" {
|
|
|
|
|
|
if v == "off" {
|
|
|
|
|
|
cfg.QuietHours = ""
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if _, err := queue.ParseQuietHours(v); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("TALK_QUIET_HOURS: invalid window %q (HH:MM-HH:MM, \"off\" = disabled)", v)
|
2026-10-18 17:40:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
cfg.QuietHours = v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := getenv("TALK_QUIET_MODE"); v != "" {
|
|
|
|
|
|
if v != queue.QuietModeDiscard && v != queue.QuietModeToast {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("TALK_QUIET_MODE: invalid value %q (allowed: %q, %q)",
|
2026-10-18 17:40:35 +02:00
|
|
|
|
v, queue.QuietModeDiscard, queue.QuietModeToast)
|
|
|
|
|
|
}
|
|
|
|
|
|
cfg.QuietMode = v
|
|
|
|
|
|
}
|
2026-10-24 09:54:40 +02:00
|
|
|
|
if cfg.Queue.MaxText < 0 || cfg.Queue.MaxAge < 0 || cfg.Queue.RateLimit < 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("queue: max_text, max_age and rate_limit must not be negative (0 = unlimited)")
|
2026-10-24 09:54:40 +02:00
|
|
|
|
}
|
2026-10-18 15:38:56 +02:00
|
|
|
|
switch cfg.Audio.Sink {
|
|
|
|
|
|
case SinkOto, SinkNull:
|
|
|
|
|
|
default:
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("audio.sink: invalid value %q (allowed: %q, %q)",
|
2026-10-18 15:38:56 +02:00
|
|
|
|
cfg.Audio.Sink, SinkOto, SinkNull)
|
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
|
if cfg.Lang != "de" && cfg.Lang != "en" {
|
|
|
|
|
|
return cfg, fmt.Errorf("lang: invalid value %q (allowed: de, en)", cfg.Lang)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Validate allowed_origins from the file (env was parsed above): an empty
|
|
|
|
|
|
// list is fine (door shut), but entries must have substance.
|
2026-10-18 20:06:05 +02:00
|
|
|
|
for _, o := range cfg.Server.AllowedOrigins {
|
|
|
|
|
|
if strings.TrimSpace(o) == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return cfg, fmt.Errorf("server.allowed_origins: empty entry in %v", cfg.Server.AllowedOrigins)
|
2026-10-18 20:06:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// Validate quiet_hours from the file (env was checked above) and lift an
|
|
|
|
|
|
// empty quiet_mode to the default.
|
2026-10-18 17:40:35 +02:00
|
|
|
|
if cfg.QuietHours != "" {
|
|
|
|
|
|
if _, err := queue.ParseQuietHours(cfg.QuietHours); err != nil {
|
|
|
|
|
|
return cfg, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if cfg.QuietMode == "" {
|
|
|
|
|
|
cfg.QuietMode = queue.QuietModeDiscard
|
|
|
|
|
|
}
|
|
|
|
|
|
return cfg, nil
|
2026-10-18 15:38:56 +02:00
|
|
|
|
}
|
2026-10-18 20:06:05 +02:00
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
|
// splitOrigins splits a comma-separated origin list (the env form of
|
|
|
|
|
|
// server.allowed_origins, for example "https://a.example, https://b.example").
|
|
|
|
|
|
// Whitespace around entries is tolerated; empty entries are an error.
|
2026-10-18 20:06:05 +02:00
|
|
|
|
func splitOrigins(v string) ([]string, error) {
|
|
|
|
|
|
parts := strings.Split(v, ",")
|
|
|
|
|
|
origins := make([]string, 0, len(parts))
|
|
|
|
|
|
for _, p := range parts {
|
|
|
|
|
|
p = strings.TrimSpace(p)
|
|
|
|
|
|
if p == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
|
return nil, fmt.Errorf("empty entry in %q (comma-separated origins, or \"off\")", v)
|
2026-10-18 20:06:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
origins = append(origins, p)
|
|
|
|
|
|
}
|
|
|
|
|
|
return origins, nil
|
|
|
|
|
|
}
|