All checks were successful
Release / Scraper / Test (push) Successful in 20s
Release / UI / Build (push) Successful in 25s
CI / Scraper / Lint (pull_request) Successful in 10s
Release / v2 / Build ui-v2 (push) Successful in 28s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 25s
Release / Scraper / Docker (push) Successful in 55s
Release / UI / Docker (push) Successful in 43s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 36s
Release / v2 / Test backend (push) Successful in 3m51s
Release / v2 / Docker / runner (push) Successful in 36s
Release / v2 / Docker / backend (push) Successful in 1m34s
iOS CI / Build (pull_request) Successful in 5m33s
iOS CI / Test (pull_request) Successful in 11m25s
- Runner fetches 9 browse combos (genre×sort×status) every 6h and stores JSON snapshots in MinIO libnovel-browse bucket (browse_refresh.go) - Backend handleBrowse reads page-1 results from MinIO first; falls back to live novelfire.net fetch; returns empty+cached:false on total failure instead of 502 - Add BrowseStore interface (bookstore.go), MinIO put/get helpers (minio.go), Store methods + compile-time assertion (store.go), BucketBrowse config, wiring in cmd/backend and cmd/runner, docker-compose-new bucket init - Fix ReapStaleTasks: PocketBase datetime fields require heartbeat_at=null (not heartbeat_at="") in filter expressions, and nil (not "") in patch payload — was causing 400 errors on every reap cycle
187 lines
5.8 KiB
Go
187 lines
5.8 KiB
Go
// 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
|
|
}
|
|
|
|
// 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
|
|
// ProxyURL is an optional outbound proxy for scraper HTTP requests.
|
|
ProxyURL string
|
|
}
|
|
|
|
// Config is the top-level configuration struct consumed by both binaries.
|
|
type Config struct {
|
|
PocketBase PocketBase
|
|
MinIO MinIO
|
|
Kokoro Kokoro
|
|
HTTP HTTP
|
|
Runner Runner
|
|
// 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", "libnovel-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),
|
|
ProxyURL: envOr("SCRAPER_PROXY", ""),
|
|
},
|
|
}
|
|
}
|
|
|
|
// ── 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
|
|
}
|