// Command runner is the homelab worker binary. // // It polls PocketBase for pending scrape and audio tasks, executes them, and // writes results back. It connects directly to PocketBase and MinIO using // admin credentials loaded from environment variables. // // Usage: // // runner # start polling loop (blocks until SIGINT/SIGTERM) package main import ( "context" "fmt" "log/slog" "os" "os/signal" "runtime" "syscall" "time" "github.com/libnovel/backend/internal/browser" "github.com/libnovel/backend/internal/config" "github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/novelfire" "github.com/libnovel/backend/internal/runner" "github.com/libnovel/backend/internal/storage" ) // version and commit are set at build time via -ldflags. var ( version = "dev" commit = "unknown" ) func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "runner: fatal: %v\n", err) os.Exit(1) } } func run() error { cfg := config.Load() // ── Logger ────────────────────────────────────────────────────────────── log := buildLogger(cfg.LogLevel) log.Info("runner starting", "version", version, "commit", commit, "worker_id", cfg.Runner.WorkerID, ) // ── Context: cancel on SIGINT / SIGTERM ───────────────────────────────── ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // ── Storage ───────────────────────────────────────────────────────────── store, err := storage.NewStore(ctx, cfg, log) if err != nil { return fmt.Errorf("init storage: %w", err) } // ── Browser / Scraper ─────────────────────────────────────────────────── workers := cfg.Runner.Workers if workers <= 0 { workers = runtime.NumCPU() } timeout := cfg.Runner.Timeout if timeout <= 0 { timeout = 90 * time.Second } browserClient := browser.NewDirectClient(browser.Config{ MaxConcurrent: workers, Timeout: timeout, ProxyURL: cfg.Runner.ProxyURL, }) novel := novelfire.New(browserClient, log) // ── Kokoro ────────────────────────────────────────────────────────────── var kokoroClient kokoro.Client if cfg.Kokoro.URL != "" { kokoroClient = kokoro.New(cfg.Kokoro.URL) log.Info("kokoro TTS enabled", "url", cfg.Kokoro.URL) } else { log.Warn("KOKORO_URL not set — audio tasks will fail") kokoroClient = &noopKokoro{} } // ── Runner ────────────────────────────────────────────────────────────── rCfg := runner.Config{ WorkerID: cfg.Runner.WorkerID, PollInterval: cfg.Runner.PollInterval, MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape, MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio, OrchestratorWorkers: workers, } deps := runner.Dependencies{ Consumer: store, BookWriter: store, BookReader: store, AudioStore: store, BrowseStore: store, Novel: novel, Kokoro: kokoroClient, Log: log, } r := runner.New(rCfg, deps) return r.Run(ctx) } // ── Helpers ─────────────────────────────────────────────────────────────────── func buildLogger(level string) *slog.Logger { var lvl slog.Level switch level { case "debug": lvl = slog.LevelDebug case "warn": lvl = slog.LevelWarn case "error": lvl = slog.LevelError default: lvl = slog.LevelInfo } return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})) } // noopKokoro is a no-op implementation used when KOKORO_URL is not set. type noopKokoro struct{} func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) { return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)") } func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) { return nil, nil }