// Package config loads all service configuration from environment variables. // Both the runner and backend binaries call config.Load() at startup; each // uses only the sub-struct relevant to it. // // Every field has a documented default so the service starts sensibly without // any environment configuration (useful for local development). package config import ( "os" "strconv" "strings" "time" ) // PocketBase holds connection settings for the remote PocketBase instance. type PocketBase struct { // URL is the base URL of the PocketBase instance, e.g. https://pb.libnovel.cc URL string // AdminEmail is the admin account email used for API authentication. AdminEmail string // AdminPassword is the admin account password. AdminPassword string } // MinIO holds connection settings for the remote MinIO / S3-compatible store. type MinIO struct { // Endpoint is the host:port of the MinIO S3 API, e.g. storage.libnovel.cc:443 Endpoint string // PublicEndpoint is the browser-visible endpoint used for presigned URLs. // Falls back to Endpoint when empty. PublicEndpoint string // AccessKey is the MinIO access key. AccessKey string // SecretKey is the MinIO secret key. SecretKey string // UseSSL enables TLS for the internal MinIO connection. UseSSL bool // PublicUseSSL enables TLS for presigned URL generation. PublicUseSSL bool // BucketChapters is the bucket that holds chapter markdown objects. BucketChapters string // BucketAudio is the bucket that holds generated audio MP3 objects. BucketAudio string // BucketAvatars is the bucket that holds user avatar images. BucketAvatars string // BucketBrowse is the bucket that holds cached browse page snapshots (JSON). BucketBrowse string } // Kokoro holds connection settings for the Kokoro-FastAPI TTS service. type Kokoro struct { // URL is the base URL of the Kokoro service, e.g. https://kokoro.libnovel.cc // An empty string disables TTS generation. URL string // DefaultVoice is the voice used when none is specified. DefaultVoice string } // HTTP holds settings for the HTTP server (backend only). type HTTP struct { // Addr is the listen address, e.g. ":8080" Addr string } // Meilisearch holds connection settings for the Meilisearch full-text search service. type Meilisearch struct { // URL is the base URL of the Meilisearch instance, e.g. http://localhost:7700 // An empty string disables Meilisearch indexing and search. URL string // APIKey is the Meilisearch master/search API key. APIKey string } // Valkey holds connection settings for the Valkey/Redis presign URL cache. type Valkey struct { // Addr is the host:port of the Valkey instance, e.g. localhost:6379 // An empty string disables the Valkey cache (falls through to MinIO directly). Addr string } // Runner holds settings specific to the runner/worker binary. type Runner struct { // PollInterval is how often the runner checks PocketBase for pending tasks. PollInterval time.Duration // MaxConcurrentScrape limits simultaneous book-scrape goroutines. MaxConcurrentScrape int // MaxConcurrentAudio limits simultaneous audio-generation goroutines. MaxConcurrentAudio int // WorkerID is a unique identifier for this runner instance. // Defaults to the system hostname. WorkerID string // Workers is the number of chapter-scraping goroutines per book. Workers int // Timeout is the per-request HTTP timeout for scraping. Timeout time.Duration // MetricsAddr is the listen address for the runner /metrics HTTP endpoint. // Defaults to ":9091". Set to "" to disable. MetricsAddr string // CatalogueRefreshInterval is how often the runner walks the full catalogue, // scrapes per-book metadata, downloads covers, and re-indexes in Meilisearch. // Defaults to 24h. Set to 0 to use the default. CatalogueRefreshInterval time.Duration // SkipInitialCatalogueRefresh prevents the runner from running a full // catalogue walk on startup. Useful for quick restarts where the catalogue // is already indexed and a 24h walk would be wasteful. // Controlled by RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true. SkipInitialCatalogueRefresh bool } // Config is the top-level configuration struct consumed by both binaries. type Config struct { PocketBase PocketBase MinIO MinIO Kokoro Kokoro HTTP HTTP Runner Runner Meilisearch Meilisearch Valkey Valkey // LogLevel is one of "debug", "info", "warn", "error". LogLevel string } // Load reads all configuration from environment variables and returns a // populated Config. Missing variables fall back to documented defaults. func Load() Config { workerID, _ := os.Hostname() if workerID == "" { workerID = "runner-default" } return Config{ LogLevel: envOr("LOG_LEVEL", "info"), PocketBase: PocketBase{ URL: envOr("POCKETBASE_URL", "http://localhost:8090"), AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"), AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"), }, MinIO: MinIO{ Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""), AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), UseSSL: envBool("MINIO_USE_SSL", false), PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true), BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "avatars"), BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"), }, Kokoro: Kokoro{ URL: envOr("KOKORO_URL", ""), DefaultVoice: envOr("KOKORO_VOICE", "af_bella"), }, HTTP: HTTP{ Addr: envOr("BACKEND_HTTP_ADDR", ":8080"), }, Runner: Runner{ PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second), MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1), MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1), WorkerID: envOr("RUNNER_WORKER_ID", workerID), Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU() Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second), MetricsAddr: envOr("RUNNER_METRICS_ADDR", ":9091"), CatalogueRefreshInterval: envDuration("RUNNER_CATALOGUE_REFRESH_INTERVAL", 0), SkipInitialCatalogueRefresh: envBool("RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH", false), }, Meilisearch: Meilisearch{ URL: envOr("MEILI_URL", ""), APIKey: envOr("MEILI_API_KEY", ""), }, Valkey: Valkey{ Addr: envOr("VALKEY_ADDR", ""), }, } } // ── helpers ─────────────────────────────────────────────────────────────────── func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } func envBool(key string, fallback bool) bool { v := os.Getenv(key) if v == "" { return fallback } return strings.ToLower(v) == "true" } func envInt(key string, fallback int) int { v := os.Getenv(key) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil || n < 0 { return fallback } return n } func envDuration(key string, fallback time.Duration) time.Duration { v := os.Getenv(key) if v == "" { return fallback } d, err := time.ParseDuration(v) if err != nil { return fallback } return d }