// 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 // BucketTranslations is the bucket that holds machine-translated chapter markdown. BucketTranslations 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://tts.libnovel.cc // An empty string disables Kokoro TTS generation. URL string // DefaultVoice is the voice used when none is specified. DefaultVoice string } // PocketTTS holds connection settings for the kyutai-labs/pocket-tts service. type PocketTTS struct { // URL is the base URL of the pocket-tts service, e.g. https://pocket-tts.libnovel.cc // An empty string disables pocket-tts generation. URL string } // LibreTranslate holds connection settings for a self-hosted LibreTranslate instance. type LibreTranslate struct { // URL is the base URL of the LibreTranslate instance, e.g. https://translate.libnovel.cc // An empty string disables machine translation entirely. URL string // APIKey is the optional API key for the LibreTranslate instance. // Leave empty if the instance runs without authentication. APIKey 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 } // Redis holds connection settings for the Asynq task queue Redis instance. // This is separate from Valkey (presign cache) — it may point to the same // Redis or a dedicated one. An empty Addr falls back to PocketBase polling. type Redis struct { // Addr is the host:port (or rediss://... URL) of the Redis instance. // Use rediss:// scheme for TLS (e.g. rediss://:password@redis.libnovel.cc:6380). // An empty string disables Asynq and falls back to PocketBase polling. Addr string // Password is the Redis AUTH password. // Not needed when Addr is a full rediss:// URL that includes the password. Password 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 // MaxConcurrentTranslation limits simultaneous translation goroutines. MaxConcurrentTranslation 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 // CatalogueRequestDelay is the base delay inserted between per-book metadata // requests during a catalogue refresh. A random jitter of up to 50% is added // on top. Defaults to 2s. Increase to reduce 429 pressure on novelfire.net. // Controlled by RUNNER_CATALOGUE_REQUEST_DELAY (e.g. "3s", "500ms"). CatalogueRequestDelay time.Duration } // Config is the top-level configuration struct consumed by both binaries. type Config struct { PocketBase PocketBase MinIO MinIO Kokoro Kokoro PocketTTS PocketTTS LibreTranslate LibreTranslate HTTP HTTP Runner Runner Meilisearch Meilisearch Valkey Valkey Redis Redis // 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", "chapters"), BucketAudio: envOr("MINIO_BUCKET_AUDIO", "audio"), BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "avatars"), BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "catalogue"), BucketTranslations: envOr("MINIO_BUCKET_TRANSLATIONS", "translations"), }, Kokoro: Kokoro{ URL: envOr("KOKORO_URL", ""), DefaultVoice: envOr("KOKORO_VOICE", "af_bella"), }, PocketTTS: PocketTTS{ URL: envOr("POCKET_TTS_URL", ""), }, LibreTranslate: LibreTranslate{ URL: envOr("LIBRETRANSLATE_URL", ""), APIKey: envOr("LIBRETRANSLATE_API_KEY", ""), }, 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), MaxConcurrentTranslation: envInt("RUNNER_MAX_CONCURRENT_TRANSLATION", 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), CatalogueRequestDelay: envDuration("RUNNER_CATALOGUE_REQUEST_DELAY", 2*time.Second), }, Meilisearch: Meilisearch{ URL: envOr("MEILI_URL", ""), APIKey: envOr("MEILI_API_KEY", ""), }, Valkey: Valkey{ Addr: envOr("VALKEY_ADDR", ""), }, Redis: Redis{ Addr: envOr("REDIS_ADDR", ""), Password: envOr("REDIS_PASSWORD", ""), }, } } // ── 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 }