- Service worker (src/service-worker.ts) handles push events and notification clicks, navigating to the book page on tap - Web app manifest (manifest.webmanifest) linked in app.html - Profile page: push notification toggle (subscribe/unsubscribe) using the browser Notification + PushManager API with VAPID - API route POST/DELETE /api/push-subscription proxies to backend - Go backend: push_subscriptions PocketBase collection storage methods (SavePushSubscription, DeletePushSubscription, ListPushSubscriptionsByBook) in storage/store.go - handlers_push.go: GET vapid-public-key, POST/DELETE subscription - webpush package: VAPID-signed sends via webpush-go, SendToBook fans out to all users who have the book in their library - Runner fires push to subscribers whenever ChaptersScraped > 0 after a successful book scrape - Config: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT env vars - domain.ScrapeResult gets a Slug field; orchestrator populates it
324 lines
11 KiB
Go
324 lines
11 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
|
|
// 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
|
|
}
|
|
|
|
// CFAI holds credentials for Cloudflare Workers AI TTS.
|
|
type CFAI struct {
|
|
// AccountID is the Cloudflare account ID.
|
|
// An empty string disables CF AI generation.
|
|
AccountID string
|
|
// APIToken is a Workers AI API token with Workers AI Read+Edit permissions.
|
|
APIToken string
|
|
// Model is the Workers AI TTS model ID.
|
|
// Defaults to "@cf/deepgram/aura-2-en" when empty.
|
|
Model 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
|
|
}
|
|
|
|
// VAPID holds Web Push VAPID key pair for browser push notifications.
|
|
// Generate a pair once with: go run ./cmd/genkeys (or use the web-push CLI).
|
|
// The public key is exposed via GET /api/push-subscriptions/vapid-public-key
|
|
// and embedded in the SvelteKit app via PUBLIC_VAPID_PUBLIC_KEY.
|
|
type VAPID struct {
|
|
// PublicKey is the base64url-encoded VAPID public key (65 bytes, uncompressed EC P-256).
|
|
PublicKey string
|
|
// PrivateKey is the base64url-encoded VAPID private key (32 bytes).
|
|
PrivateKey string
|
|
// Subject is the mailto: or https: URL used as the VAPID subscriber contact.
|
|
Subject 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
|
|
CFAI CFAI
|
|
LibreTranslate LibreTranslate
|
|
HTTP HTTP
|
|
Runner Runner
|
|
Meilisearch Meilisearch
|
|
Valkey Valkey
|
|
Redis Redis
|
|
VAPID VAPID
|
|
// 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", ""),
|
|
},
|
|
|
|
CFAI: CFAI{
|
|
AccountID: envOr("CFAI_ACCOUNT_ID", ""),
|
|
APIToken: envOr("CFAI_API_TOKEN", ""),
|
|
Model: envOr("CFAI_TTS_MODEL", ""),
|
|
},
|
|
|
|
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", ""),
|
|
},
|
|
|
|
VAPID: VAPID{
|
|
PublicKey: envOr("VAPID_PUBLIC_KEY", ""),
|
|
PrivateKey: envOr("VAPID_PRIVATE_KEY", ""),
|
|
Subject: envOr("VAPID_SUBJECT", "mailto:admin@libnovel.cc"),
|
|
},
|
|
}
|
|
}
|
|
|
|
// ── 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
|
|
}
|